From 8742e9f75fa168ed544fffc531c1c0c04a312093 Mon Sep 17 00:00:00 2001 From: Kim Neunert Date: Thu, 13 Jan 2022 09:38:44 +0100 Subject: [PATCH] Feature: Send dialog refactored and sped up (#1454) * Feature: time measuring for RPC-tracing * Performance warnings and hints * adjust treshold to 7 seconds * refactor api wallets endpoints to own file * more consistent error-handling * fee endpoint * refactoring tx-table to hide columns and add customaction * replace coin_selection with webcomponent * fix fee-test * fix fee-test * fix fee-estimation * fee selection web-component * bugfixes * fix test * using events to communicate changes * tidy up * fix selectedCoins * fix tests * no fee_rate calc in send_new endpoint and GET for fetchAssetBalances * refactor estimate_fee out from send_new * fix assetLabel * Do not use assetBalances-endpoint but pass as property * Typos corrected, RBF functionality restored, bugs in fee selection fixed and fee default settings improved. Co-authored-by: moneymanolis Co-authored-by: Manolis <70536101+moneymanolis@users.noreply.github.com> --- cypress/integration/spec_wallet_utxo.js | 14 +- docs/performance.md | 16 + mkdocs.yml | 1 + src/cryptoadvance/specter/config.py | 5 + src/cryptoadvance/specter/rpc.py | 35 +- .../specter/server_endpoints/controller.py | 32 +- .../specter/server_endpoints/wallets.py | 961 +---------------- .../specter/server_endpoints/wallets_api.py | 968 ++++++++++++++++++ .../templates/includes/address-data.html | 2 +- .../templates/includes/address-label.html | 4 +- .../templates/includes/address-row.html | 4 +- .../templates/includes/addresses-table.html | 4 +- .../templates/includes/coin_selection.html | 201 ++++ .../templates/includes/fee-selection.html | 309 ++++++ .../templates/includes/merkletooltip.html | 2 +- .../components/sidebar_wallet_list_item.jinja | 2 +- .../specter/templates/includes/tx-data.html | 4 +- .../specter/templates/includes/tx-row.html | 66 +- .../specter/templates/includes/tx-table.html | 251 +++-- .../templates/settings/tor_settings.jinja | 1 + .../wallet/addresses/wallet_addresses.jinja | 2 +- .../send/new/components/coin_selection.jinja | 61 -- .../new/components/coin_selection_item.jinja | 32 - .../new/components/coin_selection_table.jinja | 34 - .../send/new/components/fee_selection.jinja | 93 -- .../wallet/send/new/wallet_send.jinja | 92 +- .../send/sign/wallet_send_sign_psbt.jinja | 6 +- .../specter/util/fee_estimation.py | 13 +- .../specter/util/psbt_creator.py | 38 +- .../specter/util/wallet_importer.py | 2 +- src/cryptoadvance/specter/wallet.py | 2 +- ...st_controller.py => test_ep_controller.py} | 0 tests/test_ep_wallet_api.py | 34 + tests/test_util_fee_estimation.py | 23 + tests/test_util_psbt_creator.py | 4 +- tests/test_wallet_manager.py | 10 + utils/test-cypress.sh | 4 +- 37 files changed, 2020 insertions(+), 1312 deletions(-) create mode 100644 docs/performance.md create mode 100644 src/cryptoadvance/specter/server_endpoints/wallets_api.py create mode 100644 src/cryptoadvance/specter/templates/includes/coin_selection.html create mode 100644 src/cryptoadvance/specter/templates/includes/fee-selection.html delete mode 100644 src/cryptoadvance/specter/templates/wallet/send/new/components/coin_selection.jinja delete mode 100644 src/cryptoadvance/specter/templates/wallet/send/new/components/coin_selection_item.jinja delete mode 100644 src/cryptoadvance/specter/templates/wallet/send/new/components/coin_selection_table.jinja delete mode 100644 src/cryptoadvance/specter/templates/wallet/send/new/components/fee_selection.jinja rename tests/{test_controller.py => test_ep_controller.py} (100%) create mode 100644 tests/test_ep_wallet_api.py create mode 100644 tests/test_util_fee_estimation.py diff --git a/cypress/integration/spec_wallet_utxo.js b/cypress/integration/spec_wallet_utxo.js index 939debfe4..52f00e11c 100644 --- a/cypress/integration/spec_wallet_utxo.js +++ b/cypress/integration/spec_wallet_utxo.js @@ -54,12 +54,8 @@ describe('Send transactions from wallets', () => { cy.get('tx-table').shadow().find('tx-row').eq(3).shadow().find('.select-tx-img').click() cy.get('tx-table').shadow().find('.compose-tx-btn').click() - // cy.get('#coin_selection_table').find('tr').eq(0).find('.coin_select_checkbox').should('be.checked') - cy.get('.coin_select_checkbox[checked]').should('have.length', 2); - - cy.get('.coin_select_checkbox').eq(0).should('be.checked') - cy.get('.coin_select_checkbox').eq(1).should('not.be.checked') - cy.get('.coin_select_checkbox').eq(2).should('be.checked') + // If you select a coin from the utxo-set and cklick on "create transaction", the coins need to be preselected + cy.get('.coinselect-hidden').should('have.length', 3); // Unfreeze the UTXO cy.get('#btn_transactions').click() @@ -80,11 +76,7 @@ describe('Send transactions from wallets', () => { cy.get('tx-table').shadow().find('tx-row').eq(3).shadow().find('.select-tx-img').click() cy.get('tx-table').shadow().find('.compose-tx-btn').click() - cy.get('.coin_select_checkbox[checked]').should('have.length', 3); + cy.get('.coinselect-hidden').should('have.length', 3); - cy.get('.coin_select_checkbox').eq(0).should('be.checked') - cy.get('.coin_select_checkbox').eq(1).should('be.checked') - cy.get('.coin_select_checkbox').eq(2).should('not.be.checked') - cy.get('.coin_select_checkbox').eq(3).should('be.checked') }) }) \ No newline at end of file diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 000000000..ed0291a05 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,16 @@ +# Perfomance Hints + +Specter is a very flexible tool. It has several deployment models and different ways to use it. Some people use it on Node implementations like Umbrel, MyNode or RaspiBlitz. Many people use it as dedicated Electron desktop-app. Tor is also used a lot. Specter needs a Bitcoin Core node either on the same machine or on a different one. All these different usage differences do have performance impacts which might not be obvious for the average user: +* If two components are talking via the network and are not on the same computer, that might slow down things +* If components are talking via Tor, this will have an impact on performance + +So here is a small checklist which you can use to improve your performance on Specter Desktop: + +## Mempool.Space +Currently (`v1.7.0`) , Mempool.Space is the default fee-estimation implementation and it's used via Tor. Changing it to Bitcoin Core in the general settings should speed up performance. + +# Bitcoin Core via Tor +It's possible to connect to your Bitcoin Node via Tor. However, if you want to have access to your Specter instance from the street, perhaps rather expose Specter over Tor. If you want or have to run Bitcoin Core on a different machine than Specter, it is more advisable do that on the same network at home and not via Tor. + +# Tor only-mode +Yes, we have a Tor only-mode but it's also coming with a huge performance impact. Think twice before you activate it and keep it in mind while waiting. diff --git a/mkdocs.yml b/mkdocs.yml index c04f241ee..345db5410 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ nav: - Operating Guide: - connect-your-node.md - daemon.md + - performance.md - reverse-proxy.md - self-signed-certificates.md - hwibridge.md diff --git a/src/cryptoadvance/specter/config.py b/src/cryptoadvance/specter/config.py index f3c879d26..0df9d1502 100644 --- a/src/cryptoadvance/specter/config.py +++ b/src/cryptoadvance/specter/config.py @@ -135,6 +135,11 @@ class BaseConfig(object): # Babel integration. List of languages written from right to left for RTL support in the UI RTL_LANGUAGES = ["he"] + # The user will get a warning if a request takes longer than this threshold + REQUEST_TIME_WARNING_THRESHOLD = int( + os.getenv("REQUEST_TIME_WARNING_THRESHOLD", "20") + ) + class DevelopmentConfig(BaseConfig): # https://stackoverflow.com/questions/22463939/demystify-flask-app-secret-key diff --git a/src/cryptoadvance/specter/rpc.py b/src/cryptoadvance/specter/rpc.py index 3db5e61fa..a3f01adf6 100644 --- a/src/cryptoadvance/specter/rpc.py +++ b/src/cryptoadvance/specter/rpc.py @@ -1,6 +1,13 @@ +import datetime +import errno +import json import logging -import requests, urllib3, json, os -import os, sys, errno +import os +import sys + +import requests +import urllib3 + from .helpers import is_ip_private from .specter_error import SpecterError @@ -352,7 +359,7 @@ class BitcoinRPC: url = self.url if "wallet" in kwargs: url = url + "/wallet/{}".format(kwargs["wallet"]) - self.trace_call(url, payload) + ts = self.trace_call_before(url, payload) try: r = self.session.post( url, data=json.dumps(payload), headers=headers, timeout=timeout @@ -384,6 +391,7 @@ class BitcoinRPC: payload, ) ) + self.trace_call_after(url, payload, ts) self.r = r if r.status_code != 200: logger.debug(f"last call FAILED: {r.text} (raising RpcError)") @@ -394,11 +402,18 @@ class BitcoinRPC: return r @classmethod - def trace_call(cls, url, payload): - """logs out the call and its payload, reduces noise by suppressing repeated calls""" - if False: # noise-reduction - logger.debug(f"call({url}) payload:{payload}") - else: + def trace_call_before(cls, url, payload): + """get a timestamp if needed in order to measure how long the call takes""" + if logger.level == logging.DEBUG: + return datetime.datetime.now() + + @classmethod + def trace_call_after(cls, url, payload, timestamp): + """logs out the call and its payload (if necessary), reduces noise by suppressing repeated calls""" + if logger.level == logging.DEBUG: + timediff_ms = int( + (datetime.datetime.now() - timestamp).total_seconds() * 1000 + ) current_hash = hash( json.dumps({"url": url, "payload": payload}, sort_keys=True) ) @@ -416,7 +431,9 @@ class BitcoinRPC: else: cls.last_call_hash = current_hash logger.debug( - "call({: <28}) payload:{}".format("/".join(url.split("/")[3:]), payload) + "call({: <28})({: >5}ms) payload:{}".format( + "/".join(url.split("/")[3:]), timediff_ms, payload + ) ) def __getattr__(self, method): diff --git a/src/cryptoadvance/specter/server_endpoints/controller.py b/src/cryptoadvance/specter/server_endpoints/controller.py index 37a90c18a..e3c014678 100644 --- a/src/cryptoadvance/specter/server_endpoints/controller.py +++ b/src/cryptoadvance/specter/server_endpoints/controller.py @@ -1,9 +1,10 @@ import random, traceback +from time import time from binascii import unhexlify from flask import make_response from flask_wtf.csrf import CSRFError from werkzeug.exceptions import MethodNotAllowed -from flask import render_template, request, redirect, url_for, flash +from flask import render_template, request, redirect, url_for, flash, g from flask_babel import lazy_gettext as _ from flask_login import login_required, current_user from ..helpers import ( @@ -31,6 +32,7 @@ from .price import price_endpoint from .settings import settings_endpoint from .setup import setup_endpoint from .wallets import wallets_endpoint +from .wallets_api import wallets_endpoint_api from ..rpc import RpcError app.register_blueprint(auth_endpoint, url_prefix="/auth") @@ -40,6 +42,7 @@ app.register_blueprint(price_endpoint, url_prefix="/price") app.register_blueprint(settings_endpoint, url_prefix="/settings") app.register_blueprint(setup_endpoint, url_prefix="/setup") app.register_blueprint(wallets_endpoint, url_prefix="/wallets") +app.register_blueprint(wallets_endpoint_api, url_prefix="/wallets") rand = random.randint(0, 1e32) # to force style refresh @@ -145,6 +148,33 @@ def selfcheck(): app.login("admin") +@app.before_request +def slow_request_detection(): + """ """ + g.start = time() + + +@app.after_request +def after_request(response): + diff = time() - g.start + if ( + (response.response) + and (200 <= response.status_code < 300) + and (response.content_type.startswith("text/html")) + ): + threshold = app.config["REQUEST_TIME_WARNING_THRESHOLD"] + if diff > threshold: + flash( + _( + "The request before this one took {} seconds which is longer than the threshold ({}). Checkout the perfomance-improvement-hints in the documentation".format( + int(diff), threshold + ) + ), + "warning", + ) + return response + + ########## template injections ############# @app.context_processor def inject_debug(): diff --git a/src/cryptoadvance/specter/server_endpoints/wallets.py b/src/cryptoadvance/specter/server_endpoints/wallets.py index 1ff936a2a..cdd01e3e8 100644 --- a/src/cryptoadvance/specter/server_endpoints/wallets.py +++ b/src/cryptoadvance/specter/server_endpoints/wallets.py @@ -1,45 +1,24 @@ -import ast -import base64 -import csv import json import logging -import os import random -import time -from binascii import b2a_base64 -from datetime import datetime from functools import wraps -from io import StringIO -from math import isnan -from numbers import Number import requests -from flask import Blueprint, Flask +from cryptoadvance.specter.util.psbt_creator import PsbtCreator +from cryptoadvance.specter.util.wallet_importer import WalletImporter +from flask import Blueprint from flask import current_app as app from flask import flash, jsonify, redirect, render_template, request, url_for from flask_babel import lazy_gettext as _ -from flask_login import current_user, login_required -from werkzeug.wrappers import Response -from cryptoadvance.specter.util.psbt_creator import PsbtCreator +from flask_login import login_required -from cryptoadvance.specter.util.wallet_importer import WalletImporter - -from ..helpers import ( - bcur2base64, - get_devices_with_keys_by_type, - get_txid, -) -from ..key import Key -from ..persistence import delete_file -from ..rpc import RpcError -from ..specter import Specter -from ..specter_error import SpecterError, handle_exception -from ..util.base43 import b43_decode -from ..util.descriptor import AddChecksum, Descriptor -from ..util.fee_estimation import get_fees -from ..util.price_providers import get_price_at -from ..util.tx import decoderawtransaction +from ..helpers import get_devices_with_keys_by_type from ..managers.wallet_manager import purposes +from ..persistence import delete_file +from ..specter_error import SpecterError, handle_exception +from ..util.fee_estimation import get_fees + +logger = logging.getLogger(__name__) rand = random.randint(0, 1e32) # to force style refresh @@ -105,6 +84,7 @@ def failed_wallets(): delete_file(fullpath.replace(".json", "_txs.csv")) app.specter.wallet_manager.update() except Exception as e: + handle_exception(e) flash(_("Failed to delete wallet: {}").format(str(e)), "error") return redirect("/") @@ -122,7 +102,9 @@ def new_wallet_type(): try: # Make sure wallet is enabled on Bitcoin Core app.specter.rpc.listwallets() - except Exception: + except Exception as e: + handle_exception(e) + # Hmm, would be better to be more precise with this exception. Best assumption: err = _( '


Bitcoin Core is running with wallets disabled.

Please make sure disablewallet is off (set disablewallet=0 in your bitcoin.conf), then restart Bitcoin Core and try again.
See here for more information.

' ) @@ -298,7 +280,7 @@ def new_wallet(wallet_type): wallet_name, sigs_required, address_type, keys, cosigners ) except Exception as e: - app.logger.exception(e) + handle_exception(e) err = _("Failed to create wallet. Error: {}").format(e) return render_template( "wallet/new_wallet/new_wallet_keys.jinja", @@ -339,9 +321,7 @@ def new_wallet(wallet_type): try: wallet.rpc.rescanblockchain(startblock, no_wait=True) except Exception as e: - app.logger.error( - "Exception while rescanning blockchain: %e" % e - ) + handle_exception(e) err = "%r" % e wallet.getdata() return redirect( @@ -483,12 +463,6 @@ def send_new(wallet_alias): rbf_utxo = [] rbf_tx_id = "" selected_coins = request.form.getlist("coinselect") - fee_estimation = get_fees(app.specter, app.config) - fee_estimation_data = fee_estimation.result - if fee_estimation.error_message: - [flash(message, "error") for message in fee_estimation.error_messages] - - fee_rate = fee_estimation_data["hourFee"] if request.method == "POST": action = request.form.get("action") @@ -502,26 +476,16 @@ def send_new(wallet_alias): recipients_txt=request.form["recipients"], recipients_amount_unit=request.form.get("amount_unit_text"), ) - try: - psbt = psbt_creator.create_psbt(wallet) - except SpecterError as se: - err = str(se) - app.logger.error(se) - if "estimate_fee" in request.form: - return jsonify(success=False, error=str(err)) - - if err is None: - if "estimate_fee" in request.form: - return jsonify(success=True, psbt=psbt) - return render_template( - "wallet/send/sign/wallet_send_sign_psbt.jinja", - psbt=psbt, - labels=labels, - wallet_alias=wallet_alias, - wallet=wallet, - specter=app.specter, - rand=rand, - ) + psbt = psbt_creator.create_psbt(wallet) + return render_template( + "wallet/send/sign/wallet_send_sign_psbt.jinja", + psbt=psbt, + labels=labels, + wallet_alias=wallet_alias, + wallet=wallet, + specter=app.specter, + rand=rand, + ) elif action in ["rbf", "rbf_cancel"]: try: @@ -551,6 +515,7 @@ def send_new(wallet_alias): rand=rand, ) except Exception as e: + handle_exception(e) flash(_("Failed to perform RBF. Error: {}").format(e), "error") return redirect( url_for("wallets_endpoint.history", wallet_alias=wallet_alias) @@ -577,6 +542,7 @@ def send_new(wallet_alias): fee_options = "manual" rbf = True except Exception as e: + handle_exception(e) flash(_("Failed to perform RBF. Error: {}").format(e), "error") elif action == "signhotwallet": passphrase = request.form["passphrase"] @@ -597,6 +563,7 @@ def send_new(wallet_alias): signed_psbt = signed_psbt["psbt"] psbt = current_psbt.to_dict() except Exception as e: + handle_exception(e) signed_psbt = None flash(_("Failed to sign PSBT: {}").format(e), "error") else: @@ -617,13 +584,13 @@ def send_new(wallet_alias): try: rbf_utxo = wallet.get_rbf_utxo(rbf_tx_id) except Exception as e: + handle_exception(e) flash(_("Failed to get RBF coins. Error: {}").format(e), "error") show_advanced_settings = ( ui_option != "ui" or subtract or fee_options != "dynamic" - or fee_estimation_data["hourFee"] != fee_rate or not rbf or selected_coins ) @@ -641,15 +608,12 @@ def send_new(wallet_alias): subtract=subtract, subtract_from=subtract_from, fee_options=fee_options, - fee_rate=fee_rate, rbf=rbf, selected_coins=selected_coins, show_advanced_settings=show_advanced_settings, rbf_utxo=rbf_utxo, rbf_tx_id=rbf_tx_id, wallet_utxo=wallet_utxo, - fee_estimation=fee_rate, - fee_estimation_data=fee_estimation_data, wallet_alias=wallet_alias, wallet=wallet, specter=app.specter, @@ -670,7 +634,7 @@ def send_pending(wallet_alias): json.loads(request.form["pending_psbt"])["tx"]["txid"] ) except Exception as e: - app.logger.error("Could not delete Pending PSBT: %s" % e) + handle_exception(e) flash(_("Could not delete Pending PSBT!"), "error") elif action == "openpsbt": psbt = json.loads(request.form["pending_psbt"]) @@ -703,6 +667,7 @@ def import_psbt(wallet_alias): b64psbt = "".join(request.form["rawpsbt"].split()) psbt = wallet.importpsbt(b64psbt) except Exception as e: + handle_exception(e) flash(_("Could not import PSBT: {}").format(e), "error") return redirect( url_for("wallets_endpoint.import_psbt", wallet_alias=wallet_alias) @@ -770,7 +735,7 @@ def settings(wallet_alias): # This rpc call does not seem to return a result; use no_wait to ignore timeout errors wallet.rpc.rescanblockchain(startblock, no_wait=True) except Exception as e: - app.logger.error("%s while rescanblockchain" % e) + handle_exception(e) error = "%r" % e wallet.getdata() elif action == "abortrescan": @@ -839,863 +804,3 @@ def settings(wallet_alias): rand=rand, error=error, ) - - -################## Wallet util endpoints ####################### -# TODO: move these to an API endpoint - - -@wallets_endpoint.route("/wallet//combine/", methods=["POST"]) -@login_required -def combine(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - # only post requests - # FIXME: ugly... - txid = request.form.get("txid") - psbts = [request.form.get("psbt0").strip(), request.form.get("psbt1").strip()] - raw = {} - combined = None - - for i, psbt in enumerate(psbts): - if not psbt: - return _("Cannot parse empty data as PSBT"), 500 - if "UR:BYTES/" in psbt.upper(): - psbt = bcur2base64(psbt).decode() - - # if electrum then it's base43 - try: - decoded = b43_decode(psbt) - if decoded[:5] in [b"psbt\xff", b"pset\xff"]: - psbt = b2a_base64(decoded).decode() - else: - psbt = decoded.hex() - except: - pass - - psbts[i] = psbt - # psbt should start with cHNi - # if not - maybe finalized hex tx - if not psbt.startswith("cHNi") and not psbt.startswith("cHNl"): - raw["hex"] = psbt - combined = psbts[1 - i] - # check it's hex - try: - bytes.fromhex(psbt) - except: - return _("Invalid transaction format"), 500 - - try: - if "hex" in raw: - raw["complete"] = True - raw["psbt"] = combined - else: - combined = app.specter.combine(psbts) - raw = app.specter.finalize(combined) - if "psbt" not in raw: - raw["psbt"] = combined - psbt = wallet.update_pending_psbt(combined, txid, raw) - raw["devices"] = psbt["devices_signed"] - except RpcError as e: - return e.error_msg, e.status_code - except Exception as e: - return _("Unknown error: {}").format(e), 500 - return json.dumps(raw) - - -@wallets_endpoint.route("/wallet//broadcast/", methods=["POST"]) -@login_required -def broadcast(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - tx = request.form.get("tx") - res = wallet.rpc.testmempoolaccept([tx])[0] - if res["allowed"]: - app.specter.broadcast(tx) - wallet.delete_spent_pending_psbts([tx]) - return jsonify(success=True) - else: - return jsonify( - success=False, - error=_( - "Failed to broadcast transaction: transaction is invalid\n{}" - ).format(res["reject-reason"]), - ) - - -@wallets_endpoint.route( - "/wallet//broadcast_blockexplorer/", methods=["POST"] -) -@login_required -def broadcast_blockexplorer(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - tx = request.form.get("tx") - explorer = request.form.get("explorer") - use_tor = request.form.get("use_tor", "true") == "true" - res = wallet.rpc.testmempoolaccept([tx])[0] - if res["allowed"]: - try: - if app.specter.chain == "main": - url_network = "" - elif app.specter.chain == "liquidv1": - url_network = "liquid/" - elif app.specter.chain == "test" or app.specter.chain == "testnet": - url_network = "testnet/" - elif app.specter.chain == "signet": - url_network = "signet/" - else: - return jsonify( - success=False, - error=_("Failed to broadcast transaction. Network not supported."), - ) - if explorer == "mempool": - explorer = f"MEMPOOL_SPACE{'_ONION' if use_tor else ''}" - elif explorer == "blockstream": - explorer = f"BLOCKSTREAM_INFO{'_ONION' if use_tor else ''}" - else: - return jsonify( - success=False, - error=_( - "Failed to broadcast transaction. Block explorer not supported." - ), - ) - requests_session = app.specter.requests_session(force_tor=use_tor) - requests_session.post( - f"{app.config['EXPLORERS_LIST'][explorer]['url']}{url_network}api/tx", - data=tx, - ) - wallet.delete_spent_pending_psbts([tx]) - return jsonify(success=True) - except Exception as e: - return jsonify( - success=False, - error=_("Failed to broadcast transaction with error: {}").format(e), - ) - else: - return jsonify( - success=False, - error=_( - "Failed to broadcast transaction: transaction is invalid\n{}" - ).format(res["reject-reason"]), - ) - - -@wallets_endpoint.route("/wallet//decoderawtx/", methods=["GET", "POST"]) -@login_required -@app.csrf.exempt -def decoderawtx(wallet_alias): - try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - txid = request.form.get("txid", "") - if txid: - tx = wallet.rpc.gettransaction(txid) - # This is a fix for Bitcoin Core versions < v0.20 - # These do not return the blockheight as part of the `gettransaction` command - # So here we check if this property is lacking and if so - # query the blockheader based on the transaction blockhash - ##################### Remove from here after dropping Core v0.19 support ##################### - if "blockhash" in tx and "blockheight" not in tx: - tx["blockheight"] = wallet.rpc.getblockheader(tx["blockhash"])["height"] - ##################### Remove until here after dropping Core v0.19 support ##################### - - if tx["confirmations"] == 0: - tx["is_purged"] = wallet.is_tx_purged(txid) - try: - if ( - wallet.gettransaction(txid, decode=True).get("category", "") - == "receive" - ): - tx["fee"] = ( - wallet.rpc.getmempoolentry(txid)["fees"]["modified"] * -1 - ) - except Exception as e: - handle_exception(e) - app.logger.warning( - f"Failed to get fees from mempool entry for transaction: {txid}. Error: {e}" - ) - - try: - rawtx = decoderawtransaction(tx["hex"], app.specter.chain) - except: - rawtx = wallet.rpc.decoderawtransaction(tx["hex"]) - # add assets - if app.specter.is_liquid: - for v in rawtx["vin"] + rawtx["vout"]: - if "asset" in v: - v["assetlabel"] = app.specter.asset_label(v["asset"]) - - return jsonify( - success=True, - tx=tx, - rawtx=rawtx, - walletName=wallet.name, - ) - except Exception as e: - app.logger.warning("Failed to fetch transaction data. Exception: {}".format(e)) - return jsonify(success=False) - - -@wallets_endpoint.route( - "/wallet//rescan_progress", methods=["GET", "POST"] -) -@login_required -@app.csrf.exempt -def rescan_progress(wallet_alias): - try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - wallet.get_info() - return jsonify( - active=wallet.rescan_progress is not None, - progress=wallet.rescan_progress, - ) - except SpecterError as se: - app.logger.error("SpecterError while get wallet rescan_progress: %s" % se) - return {} - - -@wallets_endpoint.route("/wallet//get_label", methods=["POST"]) -@login_required -def get_label(wallet_alias): - try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - address = request.form.get("address", "") - label = wallet.getlabel(address) - return jsonify( - address=address, - label=label, - ) - except Exception as e: - handle_exception(e) - return jsonify( - success=False, - error=_("Exception trying to get address label: Error: {}").format(e), - ) - - -@wallets_endpoint.route("/wallet//set_label", methods=["POST"]) -@login_required -def set_label(wallet_alias): - - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - address = request.form["address"] - label = request.form["label"].rstrip() - wallet.setlabel(address, label) - return jsonify(success=True) - - -@wallets_endpoint.route("/wallet//txlist", methods=["POST"]) -@login_required -@app.csrf.exempt -def txlist(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - idx = int(request.form.get("idx", 0)) - limit = int(request.form.get("limit", 100)) - search = request.form.get("search", None) - sortby = request.form.get("sortby", None) - sortdir = request.form.get("sortdir", "asc") - fetch_transactions = request.form.get("fetch_transactions", False) - txlist = wallet.txlist( - fetch_transactions=fetch_transactions, - validate_merkle_proofs=app.specter.config.get("validate_merkle_proofs", False), - current_blockheight=app.specter.info["blocks"], - ) - return process_txlist( - txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir - ) - - -@wallets_endpoint.route("/wallet//utxo_list", methods=["POST"]) -@login_required -@app.csrf.exempt -def utxo_list(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - idx = int(request.form.get("idx", 0)) - limit = int(request.form.get("limit", 100)) - search = request.form.get("search", None) - sortby = request.form.get("sortby", None) - sortdir = request.form.get("sortdir", "asc") - txlist = wallet.full_utxo - for tx in txlist: - if not tx.get("label", None): - tx["label"] = wallet.getlabel(tx["address"]) - return process_txlist( - txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir - ) - - -@wallets_endpoint.route("/wallets_overview/txlist", methods=["POST"]) -@login_required -@app.csrf.exempt -def wallets_overview_txlist(): - idx = int(request.form.get("idx", 0)) - limit = int(request.form.get("limit", 100)) - search = request.form.get("search", None) - sortby = request.form.get("sortby", None) - sortdir = request.form.get("sortdir", "asc") - fetch_transactions = request.form.get("fetch_transactions", False) - txlist = app.specter.wallet_manager.full_txlist( - fetch_transactions=fetch_transactions, - validate_merkle_proofs=app.specter.config.get("validate_merkle_proofs", False), - current_blockheight=app.specter.info["blocks"], - ) - return process_txlist( - txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir - ) - - -@wallets_endpoint.route("/wallets_overview/utxo_list", methods=["POST"]) -@login_required -@app.csrf.exempt -def wallets_overview_utxo_list(): - idx = int(request.form.get("idx", 0)) - limit = int(request.form.get("limit", 100)) - search = request.form.get("search", None) - sortby = request.form.get("sortby", None) - sortdir = request.form.get("sortdir", "asc") - fetch_transactions = request.form.get("fetch_transactions", False) - txlist = app.specter.wallet_manager.full_utxo() - return process_txlist( - txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir - ) - - -@wallets_endpoint.route("/wallet//addresses_list/", methods=["POST"]) -@login_required -@app.csrf.exempt -def addresses_list(wallet_alias): - """Return a JSON with keys: - addressesList: list of addresses with the properties - (index, address, label, used, utxo, amount) - pageCount: total number of pages - POST parameters: - idx: pagination index (current page) - limit: maximum number of items on the page - sortby: field by which the list will be ordered - (index, address, label, used, utxo, amount) - sortdir: 'asc' (ascending) or 'desc' (descending) order - addressType: the current tab address type ('receive' or 'change')""" - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - - idx = int(request.form.get("idx", 0)) - limit = int(request.form.get("limit", 100)) - sortby = request.form.get("sortby", None) - sortdir = request.form.get("sortdir", "asc") - address_type = request.form.get("addressType", "receive") - - addresses_list = wallet.addresses_info(address_type == "change") - - result = process_addresses_list( - addresses_list, idx=idx, limit=limit, sortby=sortby, sortdir=sortdir - ) - - return jsonify( - addressesList=json.dumps(result["addressesList"]), - pageCount=result["pageCount"], - ) - - -@wallets_endpoint.route("/wallet//addressinfo/", methods=["POST"]) -@login_required -@app.csrf.exempt -def addressinfo(wallet_alias): - try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - address = request.form.get("address", "") - if address: - descriptor = wallet.get_descriptor(address=address) - address_info = wallet.get_address_info(address=address) - return { - "success": True, - "address": address, - "descriptor": descriptor, - "walletName": wallet.name, - "isMine": address_info and not address_info.is_external, - **address_info, - } - except Exception as e: - app.logger.warning("Failed to fetch address data. Exception: {}".format(e)) - return jsonify(success=False) - - -################## Wallet CSV export data endpoints ####################### -# Export wallet addresses list -@wallets_endpoint.route("/wallet//addresses_list.csv") -@login_required -def addresses_list_csv(wallet_alias): - """Return a CSV with addresses of the containing the - information: index, address, type, label, used, utxo and amount - of each of them. - GET parameters: sortby: field by which the CSV will be ordered - (index, address, label, used, utxo, amount) - sortdir: 'asc' (ascending) or 'desc' (descending) order - address_type: the current tab address type ('receive' or 'change') - onlyCurrentType: show all addresses (if false) or just the current - type (address_type param)""" - - try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - - sortby = request.args.get("sortby", "index") - sortdir = request.args.get("sortdir", "asc") - address_type = request.args.get("addressType", "receive") - only_current_type = request.args.get("onlyCurrentType", "false") == "true" - - if not only_current_type: - receive_list = wallet.addresses_info(False) - change_list = wallet.addresses_info(True) - - receive_result = process_addresses_list( - receive_list, idx=0, limit=0, sortby=sortby, sortdir=sortdir - ) - - change_result = process_addresses_list( - change_list, idx=0, limit=0, sortby=sortby, sortdir=sortdir - ) - - addressesList = ( - receive_result["addressesList"] + change_result["addressesList"] - ) - else: - addresses_list = wallet.addresses_info(address_type == "change") - - result = process_addresses_list( - addresses_list, idx=0, limit=0, sortby=sortby, sortdir=sortdir - ) - - addressesList = result["addressesList"] - - # stream the response as the data is generated - response = Response( - wallet_addresses_list_to_csv(addressesList), - mimetype="text/csv", - ) - # add a filename - response.headers.set( - "Content-Disposition", "attachment", filename="addresses_list.csv" - ) - return response - except Exception as e: - app.logger.error("Failed to export addresses list. Error: %s" % e) - flash(_("Failed to export addresses list. Error: {}").format(e), "error") - return redirect(url_for("index")) - - -# Export wallet transaction history -@wallets_endpoint.route("/wallet//transactions.csv") -@login_required -def tx_history_csv(wallet_alias): - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - validate_merkle_proofs = app.specter.config.get("validate_merkle_proofs", False) - txlist = wallet.txlist(validate_merkle_proofs=validate_merkle_proofs) - search = request.args.get("search", None) - sortby = request.args.get("sortby", "time") - sortdir = request.args.get("sortdir", "desc") - txlist = json.loads( - process_txlist( - txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir - )["txlist"] - ) - includePricesHistory = request.args.get("exportPrices", "false") == "true" - - # stream the response as the data is generated - response = Response( - txlist_to_csv(wallet, txlist, app.specter, current_user, includePricesHistory), - mimetype="text/csv", - ) - # add a filename - response.headers.set( - "Content-Disposition", "attachment", filename="transactions.csv" - ) - return response - - -# Export wallet UTXO list -@wallets_endpoint.route("/wallet//utxo.csv") -@login_required -def utxo_csv(wallet_alias): - try: - wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - includePricesHistory = request.args.get("exportPrices", "false") == "true" - search = request.args.get("search", None) - sortby = request.args.get("sortby", "time") - sortdir = request.args.get("sortdir", "desc") - txlist = json.loads( - process_txlist( - wallet.full_utxo, - idx=0, - limit=0, - search=search, - sortby=sortby, - sortdir=sortdir, - )["txlist"] - ) - # stream the response as the data is generated - response = Response( - txlist_to_csv( - wallet, - txlist, - app.specter, - current_user, - includePricesHistory, - ), - mimetype="text/csv", - ) - # add a filename - response.headers.set("Content-Disposition", "attachment", filename="utxo.csv") - return response - except Exception as e: - logging.exception(e) - return _("Failed to export wallet utxo. Error: {}").format(e), 500 - - -# Export all wallets transaction history combined -@wallets_endpoint.route("/wallets_overview/full_transactions.csv") -@login_required -def wallet_overview_txs_csv(): - try: - validate_merkle_proofs = app.specter.config.get("validate_merkle_proofs", False) - txlist = app.specter.wallet_manager.full_txlist( - validate_merkle_proofs=validate_merkle_proofs, - ) - search = request.args.get("search", None) - sortby = request.args.get("sortby", "time") - sortdir = request.args.get("sortdir", "desc") - txlist = json.loads( - process_txlist( - txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir - )["txlist"] - ) - includePricesHistory = request.args.get("exportPrices", "false") == "true" - # stream the response as the data is generated - response = Response( - txlist_to_csv( - None, txlist, app.specter, current_user, includePricesHistory - ), - mimetype="text/csv", - ) - # add a filename - response.headers.set( - "Content-Disposition", "attachment", filename="full_transactions.csv" - ) - return response - except Exception as e: - logging.exception(e) - return _("Failed to export wallets overview history. Error: {}").format(e), 500 - - -# Export all wallets transaction history combined -@wallets_endpoint.route("/wallets_overview/full_utxo.csv") -@login_required -def wallet_overview_utxo_csv(): - try: - txlist = app.specter.wallet_manager.full_utxo() - search = request.args.get("search", None) - sortby = request.args.get("sortby", "time") - sortdir = request.args.get("sortdir", "desc") - txlist = json.loads( - process_txlist( - txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir - )["txlist"] - ) - includePricesHistory = request.args.get("exportPrices", "false") == "true" - # stream the response as the data is generated - response = Response( - txlist_to_csv( - None, txlist, app.specter, current_user, includePricesHistory - ), - mimetype="text/csv", - ) - # add a filename - response.headers.set( - "Content-Disposition", "attachment", filename="full_utxo.csv" - ) - return response - except Exception as e: - logging.exception(e) - return _("Failed to export wallets overview utxo. Error: {}").format(e), 500 - - -################## Helpers ####################### - -# Transactions list to user-friendly CSV format -def txlist_to_csv(wallet, _txlist, specter, current_user, includePricesHistory=False): - txlist = [] - for tx in _txlist: - if isinstance(tx["address"], list): - _tx = tx.copy() - for i in range(0, len(tx["address"])): - _tx["address"] = tx["address"][i] - _tx["amount"] = tx["amount"][i] - txlist.append(_tx.copy()) - else: - txlist.append(tx.copy()) - data = StringIO() - w = csv.writer(data) - # write header - symbol = "USD" - if specter.price_provider.endswith("_eur"): - symbol = "EUR" - elif specter.price_provider.endswith("_gbp"): - symbol = "GBP" - row = ( - _("Date"), - _("Label"), - _("Category"), - _("Amount ({})").format(specter.unit.upper()), - _("Value ({})").format(symbol), - _("Rate (BTC/{})").format(symbol) - if specter.unit != "sat" - else _("Rate ({}/SAT)").format(symbol), - _("TxID"), - _("Address"), - _("Block Height"), - _("Timestamp"), - ) - if not wallet: - row = (_("Wallet"),) + row - w.writerow(row) - yield data.getvalue() - data.seek(0) - data.truncate(0) - - # write each log item - _wallet = wallet - for tx in txlist: - if not wallet: - wallet_alias = tx.get("wallet_alias", None) - try: - _wallet = specter.wallet_manager.get_by_alias(wallet_alias) - except Exception as e: - continue - label = _wallet.getlabel(tx["address"]) - if label == tx["address"]: - label = "" - tx_raw = _wallet.gettransaction(tx["txid"]) - if not tx.get("blockheight", None): - if tx_raw.get("blockheight", None): - tx["blockheight"] = tx_raw["blockheight"] - else: - tx["blockheight"] = "Unconfirmed" - if specter.unit == "sat": - value = float(tx["amount"]) - tx["amount"] = round(value * 1e8) - if includePricesHistory: - success, rate, symbol = get_price_at( - specter, current_user, timestamp=tx["time"] - ) - else: - success = False - if success: - rate = float(rate) - if specter.unit == "sat": - rate = rate / 1e8 - amount_price = float(tx["amount"]) * rate - if specter.unit == "sat": - rate = round(1 / rate) - else: - amount_price = None - rate = "-" - - row = ( - time.strftime("%Y-%m-%d", time.localtime(tx["time"])), - label, - tx["category"], - round(tx["amount"], (0 if specter.unit == "sat" else 8)), - round(amount_price * 100) / 100 if amount_price is not None else "-", - rate, - tx["txid"], - tx["address"], - tx["blockheight"], - tx["time"], - ) - if not wallet: - row = (tx.get("wallet_alias", ""),) + row - w.writerow(row) - yield data.getvalue() - data.seek(0) - data.truncate(0) - - -# Addresses list to user-friendly CSV format -def addresses_list_to_csv(wallet): - data = StringIO() - w = csv.writer(data) - # write header - row = ( - _("Address"), - _("Label"), - _("Index"), - _("Used"), - _("Current balance"), - ) - w.writerow(row) - yield data.getvalue() - data.seek(0) - data.truncate(0) - - # write each log item - for address in wallet._addresses: - address_info = wallet.get_address_info(address) - if not address_info.is_labeled and not address_info.used: - continue - row = ( - address, - address_info.label, - _("(external)") - if address_info.is_external - else ( - str(address_info.index) - + (_(" (change)") if address_info.change else "") - ), - address_info.used, - ) - if address_info.is_external: - balance_on_address = _("unknown (external address)") - else: - balance_on_address = 0 - if address_info.used: - for tx in wallet.full_utxo: - if tx.get("address", "") == address: - balance_on_address += tx.get("amount", 0) - row += (balance_on_address,) - - w.writerow(row) - yield data.getvalue() - data.seek(0) - data.truncate(0) - - -def wallet_addresses_list_to_csv(addresses_list): - """Convert a list of the wallet addresses to user-friendly CSV format - Parameters: addresses_list: a dict of addresses informations - (index, address, type, label, used, utxo and amount)""" - data = StringIO() - w = csv.writer(data) - # write header - row = ( - _("Index"), - _("Address"), - _("Type"), - _("Label"), - _("Used"), - _("UTXO"), - _("Amount (BTC)"), - ) - w.writerow(row) - yield data.getvalue() - data.seek(0) - data.truncate(0) - - # write each log item - for address_item in addresses_list: - - used = "Yes" if address_item["used"] else "No" - - row = ( - address_item["index"], - address_item["address"], - address_item["type"], - address_item["label"], - used, - address_item["utxo"], - address_item["amount"], - ) - - w.writerow(row) - yield data.getvalue() - data.seek(0) - data.truncate(0) - - -def process_txlist(txlist, idx=0, limit=100, search=None, sortby=None, sortdir="asc"): - if search: - txlist = [ - tx - for tx in txlist - if search in tx["txid"] - or ( - any(search in address for address in tx["address"]) - if isinstance(tx["address"], list) - else search in tx["address"] - ) - or ( - any(search in label for label in tx.get("label", "")) - if isinstance(tx.get("label", ""), list) - else search in tx.get("label", "") - ) - or ( - any(search in str(amount) for amount in tx["amount"]) - if isinstance(tx["amount"], list) - else search in str(tx["amount"]) - ) - or search in str(tx["confirmations"]) - or search in str(tx["time"]) - or search - in str(format(datetime.fromtimestamp(tx["time"]), "%d.%m.%Y %H:%M")) - ] - if sortby: - - def sort(tx): - val = tx.get(sortby, None) - final = val - if val: - if isinstance(val, list): - if isinstance(val[0], Number): - final = sum(val) - elif isinstance(val[0], str): - final = sorted( - val, key=lambda s: s.lower(), reverse=sortdir != "asc" - )[0].lower() - elif isinstance(val, str): - final = val.lower() - return final - - txlist = sorted(txlist, key=sort, reverse=sortdir != "asc") - if limit: - page_count = (len(txlist) // limit) + (0 if len(txlist) % limit == 0 else 1) - txlist = txlist[limit * idx : limit * (idx + 1)] - else: - page_count = 1 - # add assets - if app.specter.is_liquid: - for tx in txlist: - if "asset" in tx: - if isinstance(tx["asset"], list): - tx["assetlabel"] = [ - app.specter.asset_label(asset) for asset in tx["asset"] - ] - else: - tx["assetlabel"] = app.specter.asset_label(tx["asset"]) - return {"txlist": json.dumps(txlist), "pageCount": page_count} - - -def process_addresses_list( - addresses_list, idx=0, limit=100, sortby=None, sortdir="asc" -): - """Receive an address list as parameter and sort it or slice it for pagination. - Parameters: addresses_list: list of dict with the keys - (index, address, label, used, utxo, amount) - idx: pagination index (current page) - limit: maximum number of items on the page - sortby: field by which the list will be ordered - (index, address, label, used, utxo, amount) - sortdir: 'asc' (ascending) or 'desc' (descending) order""" - if sortby: - - def sort(addr): - val = addr.get(sortby, None) - final = val - if val: - if isinstance(val, str): - final = val.lower() - return final - - addresses_list = sorted(addresses_list, key=sort, reverse=sortdir != "asc") - - if limit: - page_count = (len(addresses_list) // limit) + ( - 0 if len(addresses_list) % limit == 0 else 1 - ) - addresses_list = addresses_list[limit * idx : limit * (idx + 1)] - else: - page_count = 1 - - return {"addressesList": addresses_list, "pageCount": page_count} diff --git a/src/cryptoadvance/specter/server_endpoints/wallets_api.py b/src/cryptoadvance/specter/server_endpoints/wallets_api.py new file mode 100644 index 000000000..373edfa15 --- /dev/null +++ b/src/cryptoadvance/specter/server_endpoints/wallets_api.py @@ -0,0 +1,968 @@ +import csv +import json +import logging +import time +from binascii import b2a_base64 +from datetime import datetime +from io import StringIO +from math import isnan +from numbers import Number + +import requests +from flask import Blueprint +from flask import current_app as app +from flask import flash, jsonify, redirect, request, url_for +from flask_babel import lazy_gettext as _ +from flask_login import current_user, login_required +from werkzeug.wrappers import Response + +from cryptoadvance.specter.util.psbt_creator import PsbtCreator + +from ..helpers import bcur2base64 +from ..rpc import RpcError +from ..server_endpoints.filters import assetlabel +from ..specter_error import SpecterError, handle_exception +from ..util.base43 import b43_decode +from ..util.descriptor import Descriptor +from ..util.fee_estimation import FeeEstimationResultEncoder, get_fees +from ..util.price_providers import get_price_at +from ..util.tx import decoderawtransaction + +logger = logging.getLogger(__name__) + +wallets_endpoint_api = Blueprint("wallets_endpoint_api", __name__) + + +@wallets_endpoint_api.route("/fees", methods=["GET"]) +@login_required +def fees(): + return json.dumps(get_fees(app.specter, app.config), cls=FeeEstimationResultEncoder) + + +@wallets_endpoint_api.route("/wallet//combine/", methods=["POST"]) +@login_required +def combine(wallet_alias): + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + # only post requests + # FIXME: ugly... + txid = request.form.get("txid") + psbts = [request.form.get("psbt0").strip(), request.form.get("psbt1").strip()] + raw = {} + combined = None + + for i, psbt in enumerate(psbts): + if not psbt: + return _("Cannot parse empty data as PSBT"), 500 + if "UR:BYTES/" in psbt.upper(): + psbt = bcur2base64(psbt).decode() + + # if electrum then it's base43 + try: + decoded = b43_decode(psbt) + if decoded[:5] in [b"psbt\xff", b"pset\xff"]: + psbt = b2a_base64(decoded).decode() + else: + psbt = decoded.hex() + except: + pass + + psbts[i] = psbt + # psbt should start with cHNi + # if not - maybe finalized hex tx + if not psbt.startswith("cHNi") and not psbt.startswith("cHNl"): + raw["hex"] = psbt + combined = psbts[1 - i] + # check it's hex + try: + bytes.fromhex(psbt) + except: + return _("Invalid transaction format"), 500 + + try: + if "hex" in raw: + raw["complete"] = True + raw["psbt"] = combined + else: + combined = app.specter.combine(psbts) + raw = app.specter.finalize(combined) + if "psbt" not in raw: + raw["psbt"] = combined + psbt = wallet.update_pending_psbt(combined, txid, raw) + raw["devices"] = psbt["devices_signed"] + except RpcError as e: + return e.error_msg, e.status_code + except Exception as e: + handle_exception(e) + return _("Unknown error: {}").format(e), 500 + return json.dumps(raw) + + +@wallets_endpoint_api.route("/wallet//broadcast/", methods=["POST"]) +@login_required +def broadcast(wallet_alias): + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + tx = request.form.get("tx") + res = wallet.rpc.testmempoolaccept([tx])[0] + if res["allowed"]: + app.specter.broadcast(tx) + wallet.delete_spent_pending_psbts([tx]) + return jsonify(success=True) + else: + return jsonify( + success=False, + error=_( + "Failed to broadcast transaction: transaction is invalid\n{}" + ).format(res["reject-reason"]), + ) + + +@wallets_endpoint_api.route( + "/wallet//broadcast_blockexplorer/", methods=["POST"] +) +@login_required +def broadcast_blockexplorer(wallet_alias): + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + tx = request.form.get("tx") + explorer = request.form.get("explorer") + use_tor = request.form.get("use_tor", "true") == "true" + res = wallet.rpc.testmempoolaccept([tx])[0] + if res["allowed"]: + try: + if app.specter.chain == "main": + url_network = "" + elif app.specter.chain == "liquidv1": + url_network = "liquid/" + elif app.specter.chain == "test" or app.specter.chain == "testnet": + url_network = "testnet/" + elif app.specter.chain == "signet": + url_network = "signet/" + else: + return jsonify( + success=False, + error=_("Failed to broadcast transaction. Network not supported."), + ) + if explorer == "mempool": + explorer = f"MEMPOOL_SPACE{'_ONION' if use_tor else ''}" + elif explorer == "blockstream": + explorer = f"BLOCKSTREAM_INFO{'_ONION' if use_tor else ''}" + else: + return jsonify( + success=False, + error=_( + "Failed to broadcast transaction. Block explorer not supported." + ), + ) + requests_session = app.specter.requests_session(force_tor=use_tor) + requests_session.post( + f"{app.config['EXPLORERS_LIST'][explorer]['url']}{url_network}api/tx", + data=tx, + ) + wallet.delete_spent_pending_psbts([tx]) + return jsonify(success=True) + except Exception as e: + handle_exception(e) + return jsonify( + success=False, + error=_("Failed to broadcast transaction with error: {}").format(e), + ) + else: + return jsonify( + success=False, + error=_( + "Failed to broadcast transaction: transaction is invalid\n{}" + ).format(res["reject-reason"]), + ) + + +@wallets_endpoint_api.route( + "/wallet//decoderawtx/", methods=["GET", "POST"] +) +@login_required +@app.csrf.exempt +def decoderawtx(wallet_alias): + try: + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + txid = request.form.get("txid", "") + if txid: + tx = wallet.rpc.gettransaction(txid) + # This is a fix for Bitcoin Core versions < v0.20 + # These do not return the blockheight as part of the `gettransaction` command + # So here we check if this property is lacking and if so + # query the blockheader based on the transaction blockhash + ##################### Remove from here after dropping Core v0.19 support ##################### + if "blockhash" in tx and "blockheight" not in tx: + tx["blockheight"] = wallet.rpc.getblockheader(tx["blockhash"])["height"] + ##################### Remove until here after dropping Core v0.19 support ##################### + + if tx["confirmations"] == 0: + tx["is_purged"] = wallet.is_tx_purged(txid) + try: + if ( + wallet.gettransaction(txid, decode=True).get("category", "") + == "receive" + ): + tx["fee"] = ( + wallet.rpc.getmempoolentry(txid)["fees"]["modified"] * -1 + ) + except Exception as e: + handle_exception(e) + app.logger.warning( + f"Failed to get fees from mempool entry for transaction: {txid}. Error: {e}" + ) + + try: + rawtx = decoderawtransaction(tx["hex"], app.specter.chain) + except: + rawtx = wallet.rpc.decoderawtransaction(tx["hex"]) + # add assets + if app.specter.is_liquid: + for v in rawtx["vin"] + rawtx["vout"]: + if "asset" in v: + v["assetlabel"] = app.specter.asset_label(v["asset"]) + + return jsonify( + success=True, + tx=tx, + rawtx=rawtx, + walletName=wallet.name, + ) + except Exception as e: + handle_exception(e) + return jsonify(success=False) + + +@wallets_endpoint_api.route( + "/wallet//rescan_progress", methods=["GET", "POST"] +) +@login_required +@app.csrf.exempt +def rescan_progress(wallet_alias): + try: + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + wallet.get_info() + return jsonify( + active=wallet.rescan_progress is not None, + progress=wallet.rescan_progress, + ) + except SpecterError as se: + app.logger.error("SpecterError while get wallet rescan_progress: %s" % se) + return {} + + +@wallets_endpoint_api.route("/wallet//get_label", methods=["POST"]) +@login_required +def get_label(wallet_alias): + try: + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + address = request.form.get("address", "") + label = wallet.getlabel(address) + return jsonify( + address=address, + label=label, + ) + except Exception as e: + handle_exception(e) + return jsonify( + success=False, + error=_("Exception trying to get address label: Error: {}").format(e), + ) + + +@wallets_endpoint_api.route("/wallet//set_label", methods=["POST"]) +@login_required +def set_label(wallet_alias): + + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + address = request.form["address"] + label = request.form["label"].rstrip() + wallet.setlabel(address, label) + return jsonify(success=True) + + +@wallets_endpoint_api.route("/wallet//txlist", methods=["POST"]) +@login_required +@app.csrf.exempt +def txlist(wallet_alias): + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + idx = int(request.form.get("idx", 0)) + limit = int(request.form.get("limit", 100)) + search = request.form.get("search", None) + sortby = request.form.get("sortby", None) + sortdir = request.form.get("sortdir", "asc") + fetch_transactions = request.form.get("fetch_transactions", False) + txlist = wallet.txlist( + fetch_transactions=fetch_transactions, + validate_merkle_proofs=app.specter.config.get("validate_merkle_proofs", False), + current_blockheight=app.specter.info["blocks"], + ) + return process_txlist( + txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir + ) + + +@wallets_endpoint_api.route("/wallet//utxo_list", methods=["POST"]) +@login_required +@app.csrf.exempt +def utxo_list(wallet_alias): + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + idx = int(request.form.get("idx", 0)) + limit = int(request.form.get("limit", 100)) + search = request.form.get("search", None) + sortby = request.form.get("sortby", None) + sortdir = request.form.get("sortdir", "asc") + txlist = wallet.full_utxo + for tx in txlist: + if not tx.get("label", None): + tx["label"] = wallet.getlabel(tx["address"]) + return process_txlist( + txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir + ) + + +@wallets_endpoint_api.route("/wallets_overview/txlist", methods=["POST"]) +@login_required +@app.csrf.exempt +def wallets_overview_txlist(): + idx = int(request.form.get("idx", 0)) + limit = int(request.form.get("limit", 100)) + search = request.form.get("search", None) + sortby = request.form.get("sortby", None) + sortdir = request.form.get("sortdir", "asc") + fetch_transactions = request.form.get("fetch_transactions", False) + txlist = app.specter.wallet_manager.full_txlist( + fetch_transactions=fetch_transactions, + validate_merkle_proofs=app.specter.config.get("validate_merkle_proofs", False), + current_blockheight=app.specter.info["blocks"], + ) + return process_txlist( + txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir + ) + + +@wallets_endpoint_api.route("/wallets_overview/utxo_list", methods=["POST"]) +@login_required +@app.csrf.exempt +def wallets_overview_utxo_list(): + idx = int(request.form.get("idx", 0)) + limit = int(request.form.get("limit", 100)) + search = request.form.get("search", None) + sortby = request.form.get("sortby", None) + sortdir = request.form.get("sortdir", "asc") + fetch_transactions = request.form.get("fetch_transactions", False) + txlist = app.specter.wallet_manager.full_utxo() + return process_txlist( + txlist, idx=idx, limit=limit, search=search, sortby=sortby, sortdir=sortdir + ) + + +@wallets_endpoint_api.route("/wallet//addresses_list/", methods=["POST"]) +@login_required +@app.csrf.exempt +def addresses_list(wallet_alias): + """Return a JSON with keys: + addressesList: list of addresses with the properties + (index, address, label, used, utxo, amount) + pageCount: total number of pages + POST parameters: + idx: pagination index (current page) + limit: maximum number of items on the page + sortby: field by which the list will be ordered + (index, address, label, used, utxo, amount) + sortdir: 'asc' (ascending) or 'desc' (descending) order + addressType: the current tab address type ('receive' or 'change')""" + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + + idx = int(request.form.get("idx", 0)) + limit = int(request.form.get("limit", 100)) + sortby = request.form.get("sortby", None) + sortdir = request.form.get("sortdir", "asc") + address_type = request.form.get("addressType", "receive") + + addresses_list = wallet.addresses_info(address_type == "change") + + result = process_addresses_list( + addresses_list, idx=idx, limit=limit, sortby=sortby, sortdir=sortdir + ) + + return jsonify( + addressesList=json.dumps(result["addressesList"]), + pageCount=result["pageCount"], + ) + + +@wallets_endpoint_api.route("/wallet//addressinfo/", methods=["POST"]) +@login_required +@app.csrf.exempt +def addressinfo(wallet_alias): + try: + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + address = request.form.get("address", "") + if address: + descriptor = wallet.get_descriptor(address=address) + address_info = wallet.get_address_info(address=address) + return { + "success": True, + "address": address, + "descriptor": descriptor, + "walletName": wallet.name, + "isMine": address_info and not address_info.is_external, + **address_info, + } + except Exception as e: + handle_exception(e) + return jsonify(success=False) + + +################## Wallet CSV export data endpoints ####################### +# Export wallet addresses list +@wallets_endpoint_api.route("/wallet//addresses_list.csv") +@login_required +def addresses_list_csv(wallet_alias): + """Return a CSV with addresses of the containing the + information: index, address, type, label, used, utxo and amount + of each of them. + GET parameters: sortby: field by which the CSV will be ordered + (index, address, label, used, utxo, amount) + sortdir: 'asc' (ascending) or 'desc' (descending) order + address_type: the current tab address type ('receive' or 'change') + onlyCurrentType: show all addresses (if false) or just the current + type (address_type param)""" + + try: + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + + sortby = request.args.get("sortby", "index") + sortdir = request.args.get("sortdir", "asc") + address_type = request.args.get("addressType", "receive") + only_current_type = request.args.get("onlyCurrentType", "false") == "true" + + if not only_current_type: + receive_list = wallet.addresses_info(False) + change_list = wallet.addresses_info(True) + + receive_result = process_addresses_list( + receive_list, idx=0, limit=0, sortby=sortby, sortdir=sortdir + ) + + change_result = process_addresses_list( + change_list, idx=0, limit=0, sortby=sortby, sortdir=sortdir + ) + + addressesList = ( + receive_result["addressesList"] + change_result["addressesList"] + ) + else: + addresses_list = wallet.addresses_info(address_type == "change") + + result = process_addresses_list( + addresses_list, idx=0, limit=0, sortby=sortby, sortdir=sortdir + ) + + addressesList = result["addressesList"] + + # stream the response as the data is generated + response = Response( + wallet_addresses_list_to_csv(addressesList), + mimetype="text/csv", + ) + # add a filename + response.headers.set( + "Content-Disposition", "attachment", filename="addresses_list.csv" + ) + return response + except Exception as e: + handle_exception(e) + flash(_("Failed to export addresses list. Error: {}").format(e), "error") + return redirect(url_for("index")) + + +# Export wallet transaction history +@wallets_endpoint_api.route("/wallet//transactions.csv") +@login_required +def tx_history_csv(wallet_alias): + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + validate_merkle_proofs = app.specter.config.get("validate_merkle_proofs", False) + txlist = wallet.txlist(validate_merkle_proofs=validate_merkle_proofs) + search = request.args.get("search", None) + sortby = request.args.get("sortby", "time") + sortdir = request.args.get("sortdir", "desc") + txlist = json.loads( + process_txlist( + txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir + )["txlist"] + ) + includePricesHistory = request.args.get("exportPrices", "false") == "true" + + # stream the response as the data is generated + response = Response( + txlist_to_csv(wallet, txlist, app.specter, current_user, includePricesHistory), + mimetype="text/csv", + ) + # add a filename + response.headers.set( + "Content-Disposition", "attachment", filename="transactions.csv" + ) + return response + + +# Export wallet UTXO list +@wallets_endpoint_api.route("/wallet//utxo.csv") +@login_required +def utxo_csv(wallet_alias): + try: + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + includePricesHistory = request.args.get("exportPrices", "false") == "true" + search = request.args.get("search", None) + sortby = request.args.get("sortby", "time") + sortdir = request.args.get("sortdir", "desc") + txlist = json.loads( + process_txlist( + wallet.full_utxo, + idx=0, + limit=0, + search=search, + sortby=sortby, + sortdir=sortdir, + )["txlist"] + ) + # stream the response as the data is generated + response = Response( + txlist_to_csv( + wallet, + txlist, + app.specter, + current_user, + includePricesHistory, + ), + mimetype="text/csv", + ) + # add a filename + response.headers.set("Content-Disposition", "attachment", filename="utxo.csv") + return response + except Exception as e: + handle_exception(e) + return _("Failed to export wallet utxo. Error: {}").format(e), 500 + + +@wallets_endpoint_api.route("/wallet//send/estimatefee", methods=["POST"]) +@login_required +def estimate_fee(wallet_alias): + """Returns a json-representation of a psbt which did not get persisted. Kind of a draft-run.""" + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + # update balances in the wallet + wallet.update_balance() + # update utxo list for coin selection + wallet.check_utxo() + if request.form.get("estimate_fee") != "true": + # Very critical as this form-value will prevent persisting the PSBT + return jsonify( + success=False, + error="Your Form did not specify estimate_fee = false. This call is not allowed", + ) + psbt_creator = PsbtCreator( + app.specter, + wallet, + request.form.get("ui_option", "ui"), + request_form=request.form, + recipients_txt=request.form["recipients"], + recipients_amount_unit=request.form.get("amount_unit_text"), + ) + try: + # Won't get persisted + psbt = psbt_creator.create_psbt(wallet) + return jsonify(success=True, psbt=psbt) + except SpecterError as se: + app.logger.error(se) + return jsonify(success=False, error=str(se)) + + +@wallets_endpoint_api.route("/wallet//asset_balances") +@login_required +def asset_balances(wallet_alias): + try: + wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + if app.specter.is_testnet: + label = "tBTC" + elif app.specter.is_liquid: + label = "LBTC" + else: + label = "BTC" + + amounts = [] + textUnit = app.specter.unit + asset_balances = { + "btc": { + "balance": wallet.full_available_balance, + "label": label, + }, + "sat": { + "balance": int(wallet.full_available_balance * 1e8), + "label": "sat", + }, + } + for asset in wallet.balance.get("assets", {}).keys(): + asset_balances["asset"] = { + "balance": wallet.balance.get("assets", {}) + .get(asset, {}) + .get("trusted", 0), + "label": assetlabel(None, asset), + } + return asset_balances + except Exception as e: + handle_exception(e) + return _("Failed to list asses_balances. Error: {}").format(e), 500 + + +# Export all wallets transaction history combined +@wallets_endpoint_api.route("/wallets_overview/full_transactions.csv") +@login_required +def wallet_overview_txs_csv(): + try: + validate_merkle_proofs = app.specter.config.get("validate_merkle_proofs", False) + txlist = app.specter.wallet_manager.full_txlist( + validate_merkle_proofs=validate_merkle_proofs, + ) + search = request.args.get("search", None) + sortby = request.args.get("sortby", "time") + sortdir = request.args.get("sortdir", "desc") + txlist = json.loads( + process_txlist( + txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir + )["txlist"] + ) + includePricesHistory = request.args.get("exportPrices", "false") == "true" + # stream the response as the data is generated + response = Response( + txlist_to_csv( + None, txlist, app.specter, current_user, includePricesHistory + ), + mimetype="text/csv", + ) + # add a filename + response.headers.set( + "Content-Disposition", "attachment", filename="full_transactions.csv" + ) + return response + except Exception as e: + handle_exception(e) + return _("Failed to export wallets overview history. Error: {}").format(e), 500 + + +# Export all wallets transaction history combined +@wallets_endpoint_api.route("/wallets_overview/full_utxo.csv") +@login_required +def wallet_overview_utxo_csv(): + try: + txlist = app.specter.wallet_manager.full_utxo() + search = request.args.get("search", None) + sortby = request.args.get("sortby", "time") + sortdir = request.args.get("sortdir", "desc") + txlist = json.loads( + process_txlist( + txlist, idx=0, limit=0, search=search, sortby=sortby, sortdir=sortdir + )["txlist"] + ) + includePricesHistory = request.args.get("exportPrices", "false") == "true" + # stream the response as the data is generated + response = Response( + txlist_to_csv( + None, txlist, app.specter, current_user, includePricesHistory + ), + mimetype="text/csv", + ) + # add a filename + response.headers.set( + "Content-Disposition", "attachment", filename="full_utxo.csv" + ) + return response + except Exception as e: + handle_exception(e) + return _("Failed to export wallets overview utxo. Error: {}").format(e), 500 + + +################## Helpers ####################### + +# Transactions list to user-friendly CSV format +def txlist_to_csv(wallet, _txlist, specter, current_user, includePricesHistory=False): + txlist = [] + for tx in _txlist: + if isinstance(tx["address"], list): + _tx = tx.copy() + for i in range(0, len(tx["address"])): + _tx["address"] = tx["address"][i] + _tx["amount"] = tx["amount"][i] + txlist.append(_tx.copy()) + else: + txlist.append(tx.copy()) + data = StringIO() + w = csv.writer(data) + # write header + symbol = "USD" + if specter.price_provider.endswith("_eur"): + symbol = "EUR" + elif specter.price_provider.endswith("_gbp"): + symbol = "GBP" + row = ( + _("Date"), + _("Label"), + _("Category"), + _("Amount ({})").format(specter.unit.upper()), + _("Value ({})").format(symbol), + _("Rate (BTC/{})").format(symbol) + if specter.unit != "sat" + else _("Rate ({}/SAT)").format(symbol), + _("TxID"), + _("Address"), + _("Block Height"), + _("Timestamp"), + ) + if not wallet: + row = (_("Wallet"),) + row + w.writerow(row) + yield data.getvalue() + data.seek(0) + data.truncate(0) + + # write each log item + _wallet = wallet + for tx in txlist: + if not wallet: + wallet_alias = tx.get("wallet_alias", None) + try: + _wallet = specter.wallet_manager.get_by_alias(wallet_alias) + except Exception as e: + continue + label = _wallet.getlabel(tx["address"]) + if label == tx["address"]: + label = "" + tx_raw = _wallet.gettransaction(tx["txid"]) + if not tx.get("blockheight", None): + if tx_raw.get("blockheight", None): + tx["blockheight"] = tx_raw["blockheight"] + else: + tx["blockheight"] = "Unconfirmed" + if specter.unit == "sat": + value = float(tx["amount"]) + tx["amount"] = round(value * 1e8) + if includePricesHistory: + success, rate, symbol = get_price_at( + specter, current_user, timestamp=tx["time"] + ) + else: + success = False + if success: + rate = float(rate) + if specter.unit == "sat": + rate = rate / 1e8 + amount_price = float(tx["amount"]) * rate + if specter.unit == "sat": + rate = round(1 / rate) + else: + amount_price = None + rate = "-" + + row = ( + time.strftime("%Y-%m-%d", time.localtime(tx["time"])), + label, + tx["category"], + round(tx["amount"], (0 if specter.unit == "sat" else 8)), + round(amount_price * 100) / 100 if amount_price is not None else "-", + rate, + tx["txid"], + tx["address"], + tx["blockheight"], + tx["time"], + ) + if not wallet: + row = (tx.get("wallet_alias", ""),) + row + w.writerow(row) + yield data.getvalue() + data.seek(0) + data.truncate(0) + + +# Addresses list to user-friendly CSV format +def addresses_list_to_csv(wallet): + data = StringIO() + w = csv.writer(data) + # write header + row = ( + _("Address"), + _("Label"), + _("Index"), + _("Used"), + _("Current balance"), + ) + w.writerow(row) + yield data.getvalue() + data.seek(0) + data.truncate(0) + + # write each log item + for address in wallet._addresses: + address_info = wallet.get_address_info(address) + if not address_info.is_labeled and not address_info.used: + continue + row = ( + address, + address_info.label, + _("(external)") + if address_info.is_external + else ( + str(address_info.index) + + (_(" (change)") if address_info.change else "") + ), + address_info.used, + ) + if address_info.is_external: + balance_on_address = _("unknown (external address)") + else: + balance_on_address = 0 + if address_info.used: + for tx in wallet.full_utxo: + if tx.get("address", "") == address: + balance_on_address += tx.get("amount", 0) + row += (balance_on_address,) + + w.writerow(row) + yield data.getvalue() + data.seek(0) + data.truncate(0) + + +def wallet_addresses_list_to_csv(addresses_list): + """Convert a list of the wallet addresses to user-friendly CSV format + Parameters: addresses_list: a dict of addresses informations + (index, address, type, label, used, utxo and amount)""" + data = StringIO() + w = csv.writer(data) + # write header + row = ( + _("Index"), + _("Address"), + _("Type"), + _("Label"), + _("Used"), + _("UTXO"), + _("Amount (BTC)"), + ) + w.writerow(row) + yield data.getvalue() + data.seek(0) + data.truncate(0) + + # write each log item + for address_item in addresses_list: + + used = "Yes" if address_item["used"] else "No" + + row = ( + address_item["index"], + address_item["address"], + address_item["type"], + address_item["label"], + used, + address_item["utxo"], + address_item["amount"], + ) + + w.writerow(row) + yield data.getvalue() + data.seek(0) + data.truncate(0) + + +def process_txlist(txlist, idx=0, limit=100, search=None, sortby=None, sortdir="asc"): + if search: + txlist = [ + tx + for tx in txlist + if search in tx["txid"] + or ( + any(search in address for address in tx["address"]) + if isinstance(tx["address"], list) + else search in tx["address"] + ) + or ( + any(search in label for label in tx.get("label", "")) + if isinstance(tx.get("label", ""), list) + else search in tx.get("label", "") + ) + or ( + any(search in str(amount) for amount in tx["amount"]) + if isinstance(tx["amount"], list) + else search in str(tx["amount"]) + ) + or search in str(tx["confirmations"]) + or search in str(tx["time"]) + or search + in str(format(datetime.fromtimestamp(tx["time"]), "%d.%m.%Y %H:%M")) + ] + if sortby: + + def sort(tx): + val = tx.get(sortby, None) + final = val + if val: + if isinstance(val, list): + if isinstance(val[0], Number): + final = sum(val) + elif isinstance(val[0], str): + final = sorted( + val, key=lambda s: s.lower(), reverse=sortdir != "asc" + )[0].lower() + elif isinstance(val, str): + final = val.lower() + return final + + txlist = sorted(txlist, key=sort, reverse=sortdir != "asc") + if limit: + page_count = (len(txlist) // limit) + (0 if len(txlist) % limit == 0 else 1) + txlist = txlist[limit * idx : limit * (idx + 1)] + else: + page_count = 1 + # add assets + if app.specter.is_liquid: + for tx in txlist: + if "asset" in tx: + if isinstance(tx["asset"], list): + tx["assetlabel"] = [ + app.specter.asset_label(asset) for asset in tx["asset"] + ] + else: + tx["assetlabel"] = app.specter.asset_label(tx["asset"]) + return {"txlist": json.dumps(txlist), "pageCount": page_count} + + +def process_addresses_list( + addresses_list, idx=0, limit=100, sortby=None, sortdir="asc" +): + """Receive an address list as parameter and sort it or slice it for pagination. + Parameters: addresses_list: list of dict with the keys + (index, address, label, used, utxo, amount) + idx: pagination index (current page) + limit: maximum number of items on the page + sortby: field by which the list will be ordered + (index, address, label, used, utxo, amount) + sortdir: 'asc' (ascending) or 'desc' (descending) order""" + if sortby: + + def sort(addr): + val = addr.get(sortby, None) + final = val + if val: + if isinstance(val, str): + final = val.lower() + return final + + addresses_list = sorted(addresses_list, key=sort, reverse=sortdir != "asc") + + if limit: + page_count = (len(addresses_list) // limit) + ( + 0 if len(addresses_list) % limit == 0 else 1 + ) + addresses_list = addresses_list[limit * idx : limit * (idx + 1)] + else: + page_count = 1 + + return {"addressesList": addresses_list, "pageCount": page_count} diff --git a/src/cryptoadvance/specter/templates/includes/address-data.html b/src/cryptoadvance/specter/templates/includes/address-data.html index fc4e8e24f..5b95a087c 100644 --- a/src/cryptoadvance/specter/templates/includes/address-data.html +++ b/src/cryptoadvance/specter/templates/includes/address-data.html @@ -49,7 +49,7 @@ } async fetchAddressData() { - let url = `{{ url_for('wallets_endpoint.addressinfo', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); + let url = `{{ url_for('wallets_endpoint_api.addressinfo', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); var formData = new FormData(); formData.append('address', this.address); formData.append('csrf_token', '{{ csrf_token() }}'); diff --git a/src/cryptoadvance/specter/templates/includes/address-label.html b/src/cryptoadvance/specter/templates/includes/address-label.html index a0aa09639..2c41e72c1 100644 --- a/src/cryptoadvance/specter/templates/includes/address-label.html +++ b/src/cryptoadvance/specter/templates/includes/address-label.html @@ -206,7 +206,7 @@ } async fetchAddressLabel() { - let url = `{{ url_for('wallets_endpoint.get_label', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); + let url = `{{ url_for('wallets_endpoint_api.get_label', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); var formData = new FormData(); formData.append('address', this.address); formData.append('csrf_token', '{{ csrf_token() }}'); @@ -237,7 +237,7 @@ } async setAddressLabel() { - let url = `{{ url_for('wallets_endpoint.set_label', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); + let url = `{{ url_for('wallets_endpoint_api.set_label', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); var formData = new FormData(); formData.append('address', this.address); formData.append('label', this.label.innerText); diff --git a/src/cryptoadvance/specter/templates/includes/address-row.html b/src/cryptoadvance/specter/templates/includes/address-row.html index 93ef213e9..6fb2e9d34 100644 --- a/src/cryptoadvance/specter/templates/includes/address-row.html +++ b/src/cryptoadvance/specter/templates/includes/address-row.html @@ -12,7 +12,7 @@ - + @@ -94,7 +94,7 @@ this.verify.classList.add('hidden'); } else { this.verify.onclick = async () => { - let url = `{{ url_for('wallets_endpoint.addressinfo', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); + let url = `{{ url_for('wallets_endpoint_api.addressinfo', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); var formData = new FormData(); formData.append('address', this.addressData.address) formData.append('csrf_token', '{{ csrf_token() }}'); diff --git a/src/cryptoadvance/specter/templates/includes/addresses-table.html b/src/cryptoadvance/specter/templates/includes/addresses-table.html index e1038f342..0bdf4d9cb 100644 --- a/src/cryptoadvance/specter/templates/includes/addresses-table.html +++ b/src/cryptoadvance/specter/templates/includes/addresses-table.html @@ -408,9 +408,9 @@ return } - const url = `{{ url_for('wallets_endpoint.addresses_list', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); + const url = `{{ url_for('wallets_endpoint_api.addresses_list', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); - this.export.href = `{{ url_for('wallets_endpoint.addresses_list_csv', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); + this.export.href = `{{ url_for('wallets_endpoint_api.addresses_list_csv', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); this.export.href += `?onlyCurrentType=${this.onlyCurrentTypeSwitch.checked}&addressType=${this.listType}&sortby=${this.sortby}&sortdir=${this.sortdir}`; if (this.listType == "receive") { diff --git a/src/cryptoadvance/specter/templates/includes/coin_selection.html b/src/cryptoadvance/specter/templates/includes/coin_selection.html new file mode 100644 index 000000000..4e08ee75e --- /dev/null +++ b/src/cryptoadvance/specter/templates/includes/coin_selection.html @@ -0,0 +1,201 @@ + + + + \ No newline at end of file diff --git a/src/cryptoadvance/specter/templates/includes/fee-selection.html b/src/cryptoadvance/specter/templates/includes/fee-selection.html new file mode 100644 index 000000000..8b331a558 --- /dev/null +++ b/src/cryptoadvance/specter/templates/includes/fee-selection.html @@ -0,0 +1,309 @@ + + + \ No newline at end of file diff --git a/src/cryptoadvance/specter/templates/includes/merkletooltip.html b/src/cryptoadvance/specter/templates/includes/merkletooltip.html index f172db339..24b5989bf 100644 --- a/src/cryptoadvance/specter/templates/includes/merkletooltip.html +++ b/src/cryptoadvance/specter/templates/includes/merkletooltip.html @@ -1,4 +1,4 @@ - + {{ _('Specter-Desktop validates BIP 37 Merkle Proofs from your full node guaranteeing that transactions displayed as confirmed have been included in the specified block hash.') }}

{{ _("However, a compromised full node could return a fake block that was never mined into Bitcoin's blockchain!") }} {{ _("If you find the displayed block hash in other locations (other nodes, block explorers, etc) and you trust specter-desktop isn't lying to you, then you can be sure this transaction exists in their blockchain as well.") }}" diff --git a/src/cryptoadvance/specter/templates/includes/sidebar/components/sidebar_wallet_list_item.jinja b/src/cryptoadvance/specter/templates/includes/sidebar/components/sidebar_wallet_list_item.jinja index bc832edcb..68df8fa94 100644 --- a/src/cryptoadvance/specter/templates/includes/sidebar/components/sidebar_wallet_list_item.jinja +++ b/src/cryptoadvance/specter/templates/includes/sidebar/components/sidebar_wallet_list_item.jinja @@ -37,7 +37,7 @@ let walletRescanActive = true; async function fetchWalletRescanProgress() { try { - let url="{{ url_for('wallets_endpoint.rescan_progress', wallet_alias=wallet.alias) }}" + let url="{{ url_for('wallets_endpoint_api.rescan_progress', wallet_alias=wallet.alias) }}" const response = await fetch( url, { diff --git a/src/cryptoadvance/specter/templates/includes/tx-data.html b/src/cryptoadvance/specter/templates/includes/tx-data.html index 0fa589219..f40562021 100644 --- a/src/cryptoadvance/specter/templates/includes/tx-data.html +++ b/src/cryptoadvance/specter/templates/includes/tx-data.html @@ -44,7 +44,7 @@ } async fetchRawTx(txid) { - let url = `{{ url_for('wallets_endpoint.decoderawtx', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); + let url = `{{ url_for('wallets_endpoint_api.decoderawtx', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); var formData = new FormData(); formData.append('txid', txid); formData.append('csrf_token', '{{ csrf_token() }}'); @@ -257,7 +257,7 @@ } async fetchAddressIsMine(address) { - let url = `{{ url_for('wallets_endpoint.addressinfo', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); + let url = `{{ url_for('wallets_endpoint_api.addressinfo', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet); var formData = new FormData(); formData.append('address', address) formData.append('csrf_token', '{{ csrf_token() }}'); diff --git a/src/cryptoadvance/specter/templates/includes/tx-row.html b/src/cryptoadvance/specter/templates/includes/tx-row.html index b3bda765a..fce499fc2 100644 --- a/src/cryptoadvance/specter/templates/includes/tx-row.html +++ b/src/cryptoadvance/specter/templates/includes/tx-row.html @@ -18,23 +18,29 @@ - + + + + - + - - - - + + + + + + + -
+
@@ -47,6 +53,8 @@ var style = document.getElementById('tx-row').content; var clone = style.cloneNode(true); this.el = clone.querySelector(".tx-row"); + + this.callbackaction = clone.querySelector(".callbackaction"); this.category = clone.querySelector(".category"); this.txid = clone.querySelector(".txid .explorer-link"); this.address = clone.querySelector(".address"); @@ -75,6 +83,19 @@ this.mode = this.getAttribute('data-mode'); this.hideSensitiveInfo = this.getAttribute('data-hide-sensitive-info') == 'true'; + // Set data for customaction + this.callbackaction.childNodes[1].value=`${this.tx['txid']},${this.tx['vout']}` + this.callbackaction.childNodes[1].amount = `${this.tx['amount']}` // a bit hackish, doing that to have access via this in next line + this.callbackaction.childNodes[1].addEventListener("change", (e) => { + let event = new CustomEvent('txRowCustomSelected', { detail: { + txid: this.tx.txid, + vout: this.tx.vout, + amount: this.amount + } } + ); + this.dispatchEvent(event); + }) + // Set category image this.category.src = this.getCategoryImg(this.tx.category, this.tx.confirmations > 0); this.category.classList.add('svg-' + this.tx.category) @@ -145,7 +166,7 @@ this.amount = parseInt(this.amount * 1e8); } - if (this.amount == 1e-8 || this.amount == 0) { + if (this.amount == 1e-8 ) { //|| this.amount == 0) { this.amountText.innerText = '{{ _("Confidential") }}'; } else { if(this.hideSensitiveInfo){ @@ -234,6 +255,35 @@ } else { this.selectTxImg.classList.add('hidden'); } + + // Hide columns + this.hideColumns = this.getAttribute("hide-columns") + if (this.hideColumns != "null") { + this.hideColumns.split(" ").forEach((column_name) => { + if (column_name) { + this.shadowRoot.getElementById("column-"+ column_name).remove() + } + }) + } + } + + /** + * Returns a json from this item + */ + getTxItem() { + return this.tx + } + + isSelected() { + return this.callbackaction.childNodes[1].checked + } + + unselect() { + this.callbackaction.childNodes[1].checked = false + } + + select() { + this.callbackaction.childNodes[1].checked = true } rbfPopup(rbfType) { diff --git a/src/cryptoadvance/specter/templates/includes/tx-table.html b/src/cryptoadvance/specter/templates/includes/tx-table.html index 213a41c8d..f4c22b869 100644 --- a/src/cryptoadvance/specter/templates/includes/tx-table.html +++ b/src/cryptoadvance/specter/templates/includes/tx-table.html @@ -92,7 +92,7 @@ height: 38px; } -