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 <moneymanolis@protonmail.com>
Co-authored-by: Manolis <70536101+moneymanolis@users.noreply.github.com>
This commit is contained in:
Kim Neunert 2022-01-13 09:38:44 +01:00 committed by GitHub
parent 6f33ed14b6
commit 8742e9f75f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2020 additions and 1312 deletions

View file

@ -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')
})
})

16
docs/performance.md Normal file
View file

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

View file

@ -13,6 +13,7 @@ nav:
- Operating Guide:
- connect-your-node.md
- daemon.md
- performance.md
- reverse-proxy.md
- self-signed-certificates.md
- hwibridge.md

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/addresses_list.csv")
@login_required
def addresses_list_csv(wallet_alias):
"""Return a CSV with addresses of the <wallet_alias> 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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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/<wallet_alias>/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}

View file

@ -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() }}');

View file

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

View file

@ -12,7 +12,7 @@
</style>
<tr class="address-row">
<td class="index"></td>
<td class="scroll address">
<td class="address">
<span class="explorer-link"></span>
</td>
<td class="label optional"></td>
@ -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() }}');

View file

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

View file

@ -0,0 +1,201 @@
<template id="coin-selection">
<span class="toggle_coinselection" style="cursor: pointer;">{{ _("Coin selection") }} {% if show_advanced_settings %}&#9660;{% else %}&#9654;{% endif %}</span>
<br>
<div class="coinselection_div" style="margin: auto; max-width: 90%; display: none;">
<div class="errorMessagesDiv">
<message-box type="error" style="display: none;"></message-box>
</div>
<tx-table
class="txtable"
btc-unit="{{ specter.unit }}"
hide-sensitive-info="{{ specter.hide_sensitive_info | lower }}"
type="utxo" hide-columns="category time txid confirmations action"
blockhash="true" hide-switcher="true">
</tx-table>
</div>
</template>
<script type="module">
/**
* Custom element for showing the coin-selection-dialog
attributes:
* wallet-alias
* spendable-amount
Example:
<coin-selection
id="coinselection-webcomponent"
wallet-alias="{{wallet.alias}}"
spendable-amount='{{ wallet.full_available_balance | btcamount }}'
is-liquid="{{specter.is_liquid}}"
selected-coins="{{ selected_coins }}">
</coin-selection>
Internally, a tx-table is used which is customizable in order to be usefull here.
We're using the asset_balance endpoint to get Liquid assets.
*/
class CoinSelection extends HTMLElement {
constructor() {
super();
var shadow = this.attachShadow({mode: 'open'});
var template_content = document.getElementById('coin-selection').content;
var clone = template_content.cloneNode(true);
this.coinselectionDiv = clone.querySelector(".coinselection_div")
this.txtable = clone.querySelector(".txtable")
this.txtable.addEventListener("CustomSelected", (event) => {
this.updateLd();
this.fireChangeEvent();
} )
// ErrorMessages
this.errorMessagesDiv = clone.querySelector(".errorMessagesDiv");
// toggler
this.toggler = clone.querySelector(".toggle_coinselection")
this.toggler.addEventListener('click', (event) => {
this.toggleCoinselection()
});
this.createLighterDOMNodes()
// Attach the created element to the shadow dom
shadow.appendChild(clone);
}
/**
* Browser calls this method when the element is added to the document
* (can be called many times if an element is repeatedly added/removed)
*/
connectedCallback() {
this.walletAlias = this.getAttribute('wallet-alias');
this.spendableAmount = this.getAttribute("spendable-amount") ?? 0
if (this.getAttribute("is-liquid") == "True") {
this.classList.add("hidden")
}
this.txtable.setAttribute("wallet", this.walletAlias)
let selectedCoins = JSON.parse(this.getAttribute('selected-coins').replace(/'/g, '"'));
if (selectedCoins.length > 0) {
this.txtable.setAttribute("selected-coins", JSON.stringify(selectedCoins))
// unfold if we have preselected coins
this.toggleCoinselection()
}
}
/**
* here, we create some kind of mirror-nodes in the lighterDOM so that the outer form can
* pick the values up. They are all hidden and we'll clone them from their "peer" from the shadowDOM
* Check https://stackoverflow.com/a/38667839/330964 for details
*/
createLighterDOMNodes() {
this.ld = {} // in order to separate clearly, we store all of them here
// We won't mirror fee_options, that's too much overhead. We create a textfield there:
this.ld.div = document.createElement("div");
this.ld.div.type = "hidden";
this.appendChild(this.ld.div);
}
fireChangeEvent() {
let event = new CustomEvent('change', {});
this.dispatchEvent(event);
}
handleError(errorText) {
console.log(errorText)
const messageBox = document.createElement('message-box');
//messageBox.setType("error")
messageBox.textContent = errorText
this.errorMessagesDiv.appendChild(messageBox)
}
isCoinSelectionActive() {
return this.coinselectionDiv.style.display !== 'none';
}
getSpendableAmount(unit) {
let spendableAmount;
if (!this.isCoinSelectionActive()) {
if(unit == 'btc' || unit == 'sat'){
spendableAmount = '{{ wallet.full_available_balance | btcamount }}';
} else {
if (unit in assetBalances){
return assetBalances[unit].balance;
} else {
return 0;
}
}
} else {
spendableAmount = this.getCoinSelectedAmount;
}
return (unit == 'sat' ? spendableAmount * 100000000 : spendableAmount);
}
/**
* Updates the lightDOM: It creates one hidden input-element for each tx which is selected in the shadowDOM
*/
updateLd() {
while (this.ld.div.lastChild) {
this.ld.div.removeChild(this.ld.div.lastChild);
}
this.txtable.getSelectedTxs().forEach( (tx) => {
var element = document.createElement("input");
element.type = "hidden";
element.name = "coinselect"
element.classList.add("coinselect-hidden") // for cypress-tests
element.value= tx.txid + "," + tx.vout
this.ld.div.appendChild(element)
})
}
getCoinSelectedAmount() {
var sum = 0
this.txtable.getSelectedTxs().forEach( (tx) => {
sum = sum + tx.amount
})
return sum
}
toggleCoinselection() {
if (this.coinselectionDiv.style.display === 'block') {
this.coinselectionDiv.style.display = 'none';
this.toggler.innerHTML = `{{ _("Coin selection") }} &#9654;`;
if (this.isCoinSelectionActive()) {
toggleExpand();
}
} else {
this.coinselectionDiv.style.display = 'block';
this.toggler.innerHTML = `{{ _("Coin selection") }} &#9660;`;
}
}
/*
* Removes all coin_selection if it's no longer active
*/
toggleExpand() {
if (this.isCoinSelectionActive()) {
setVisibility('coinselection', 'none');
} else {
setVisibility('coinselection', 'block');
let coins = document.getElementsByClassName('coin_select_checkbox');
// unselect all choices
for(var i = 0; i < coins.length; i++){
coins[i].checked = false;
}
}
this.fireChangeEvent();
}
shouldSelectMoreCoins(unit, amount) {
return (unit == 'sat' ? amount / 100000000 : amount) > this.getCoinSelectedAmount() && this.isCoinSelectionActive()
}
}
customElements.define('coin-selection', CoinSelection);
</script>

View file

@ -0,0 +1,309 @@
<template id="fee-selection">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
<div class="fee_container">
<div>
<label><input type="checkbox" class="inline" name="subtract" id="subtract" {% if subtract %}checked{% endif %}> {{ _("Subtract fees from amount") }}</label>
<div class="tool-tip" style="text-align: center;">
<i class="tool-tip__icon">i</i>
<p class="tool-tip__info">
<span class="info">
<span class="info__title">{{ _("Subtract fees from amount") }}<br><br></span>
{{ _("If checked, the transaction fees will be paid off from the transaction amount.") }}<br><br>
{{ _("Otherwise, the fees will be paid in addition to the amount sent.") }}
</span>
</p>
</div>
</div>
<span id="subtract_from" class="hidden">
<br>{{ _("Subtract from recipient number:") }}
<input id="subtract_from_input" name="subtract_from" type="number" min="1" value="{{ subtract_from }}" step="1" style="width: 80px; min-width: 80px;"><br>
</span>
<br>
<div>
{{ _("Fees:") }}
<label><input type="radio" class="inline" style="margin: 0 10px 0 20px;" id="fee_option_dynamic" name="fee_options" value="dynamic" >{{ _("dynamic") }}</label>
<label><input type="radio" class="inline" style="margin: 0 10px 0 20px" id="fee_option_manual" name="fee_options" value="manual" >{{ _("manual") }}</label>
</div>
<br>
<div id = "fee_manual" style="display: none">
{{ _("Fee rate:") }}<br>
{% set min_fee = 0.1 if specter.is_liquid else 1 %}
<input type="number" class="fee_rate" name="fee_rate" id="fee_rate" min="{{min_fee}}" step="any" autocomplete="off"> sat/vbyte
<div class="note">
{{ _("leave blank to set automatically, {} sat/vbyte is the minimal fee rate.".format(min_fee)) }}
</div>
</div>
<div id ="fee_dynamic" style="display: {% if fee_options_dynamic %}block{% else %}none{% endif %}">
<div id="blocks"></div>
<input type="range" style="width: 12em" min="" max="" value="" step="1" id="fees_slider">
<input type="hidden" id="fee_rate_dynamic" name="fee_rate_dynamic" value="0">
<div>
{{ _("Estimated speed:") }} <span id="fee_rate_speed_text"></span>
<br>
<span class="note">({{ _("Fee rate:") }} <span id="fee_rate_dynamic_text"></span> sat/vbyte)</span>
</div>
</div>
<br>
<label class="rbf-label" style="display: none"><input type="checkbox" class="rbf-checkbox inline" name="rbf" id="rbf"> {{ _("RBF Enabled") }}</label>
</div>
</template>
<script type="module">
/**
* A WebComponent to enable the user to manage the fees
* The API for this component works in a way that it manages 5 hidden inputs which will expose the
* choice of the user.
* * <input type="checkbox" class="rbf-checkbox inline hidden" name="rbf" id="rbf">
* * <input id="subtract_from_input" name="subtract_from" type="number" class="hidden">
* * <input type="hidden" value="dynamic" name="fee_option">
* * <input type="number" class="fee_rate hidden" name="fee_rate" id="fee_rate" value="0.1">
* * <input type="hidden" id="fee_rate_dynamic" name="fee_rate_dynamic" value="0.1" class="hidden">
*/
class FeeSelection extends HTMLElement {
constructor() {
super();
var shadow = this.attachShadow({mode: 'open'});
var template_content = document.getElementById('fee-selection').content;
var clone = template_content.cloneNode(true);
// subtract
this.subtract = clone.querySelector("#subtract")
this.subtract.addEventListener("change", (event) => {
this.toggleSubtractFrom()
})
this.subtractFrom = clone.querySelector("#subtract_from")
this.subtractFromInput = clone.querySelector("#subtract_from_input")
// The radio-button for manual
this.fee_option_manual = clone.querySelector("#fee_option_manual")
this.fee_option_manual.addEventListener('click', (event) => {
this.showFeeOption("manual")
});
// The radio-button for dynamic
this.fee_option_dynamic = clone.querySelector("#fee_option_dynamic")
this.fee_option_dynamic.addEventListener('click', (event) => {
this.showFeeOption("dynamic")
});
// Preset: manual or dynamic
this.feeOptionPreset = this.getAttribute('fee-option-preset') == null ? "dynamic" : this.getAttribute('fee-option-preset')
// The manual stuff
this.feeRate = clone.querySelector(".fee_rate")
// The slider
this.feesSlider = clone.querySelector("#fees_slider")
this.feesSlider.addEventListener("input", (event) => {
this.dynamicFeeUpdated()
})
this.feeRateDynamic = clone.querySelector("#fee_rate_dynamic")
this.feeRateDynamicText = clone.querySelector("#fee_rate_dynamic_text")
this.fee_manual = clone.querySelector("#fee_manual")
this.fee_dynamic = clone.querySelector("#fee_dynamic")
// A text explaining the rate like: "Fast (30 minutes)"
this.fee_rate_speed_text = clone.querySelector("#fee_rate_speed_text")
// RBF
this.rbfEnabled = this.getAttribute('rbf-enabled') == null ? true : this.getAttribute('rbf-enabled').toLowerCase() == "true" ?? true
this.rbfLabel = clone.querySelector(".rbf-label")
this.rbfCheckbox = clone.querySelector(".rbf-checkbox")
this.rbfCheckboxChecked = this.getAttribute('rbf-checked') == null ? false : this.getAttribute('rbf-checked').toLowerCase() == "true"
this.createLighterDOMNodes()
// Attach the created element to the shadow dom
shadow.appendChild(clone);
}
/**
* here, we create some kind of mirror-nodes in the lighterDOM so that the outer form can
* pick the values up. They are all hidden and we'll clone them from their "peer" from the shadowDOM
* Check https://stackoverflow.com/a/38667839/330964 for details
*/
createLighterDOMNodes() {
this.ld = {} // in order to separate clearly, we store all of them here
this.ld.rbfCheckbox = this.rbfCheckbox.cloneNode(true)
this.ld.rbfCheckbox.classList.add("hidden")
this.appendChild(this.ld.rbfCheckbox)
this.ld.subtract = this.subtract.cloneNode(true)
this.ld.subtract.classList.add("hidden")
this.appendChild(this.ld.subtract)
this.ld.subtractFromInput = this.subtractFromInput.cloneNode(true)
this.ld.subtractFromInput.classList.add("hidden")
this.appendChild(this.ld.subtractFromInput)
// We need to expose:
// * fee_options (either manual or dynamic)
// * fee_rate_dynamic
// * fee_rate
// We won't mirror fee_options, that's too much overhead. We create a textfield there:
this.ld.feeOption = document.createElement("input");
this.ld.feeOption.type = "hidden";
this.ld.feeOption.value = this.feeOptionPreset;
this.ld.feeOption.name = "fee_option";
this.appendChild(this.ld.feeOption);
this.ld.feeRate = this.feeRate.cloneNode(true)
this.ld.feeRate.classList.add("hidden")
this.ld.feeRate.type = "hidden"
this.appendChild(this.ld.feeRate)
this.ld.feeRateDynamic = this.feeRateDynamic.cloneNode(true)
this.ld.feeRateDynamic.classList.add("hidden")
this.ld.feeRateDynamic.type = "hidden"
this.appendChild(this.ld.feeRateDynamic)
}
/**
* Call this to figure out what the user has chosen
* returns fees and also updates Light DOM
*/
selectedFee() {
if (this.ld.feeOption == "manual") {
return this.feeRate.value
} else {
{% if specter.is_liquid %}
const MIN_FEE_RATE = 0.1;
const FEE_RATE_STEP = 0.01;
{% else %}
const MIN_FEE_RATE = 1;
const FEE_RATE_STEP = 0.5;
{% endif %}
let manualRate = Math.round(this.feeRateDynamic.value / FEE_RATE_STEP) * FEE_RATE_STEP;
if (manualRate < MIN_FEE_RATE) {
manualRate = MIN_FEE_RATE;
}
// This triggers update of ld.feeRate (because of event listener in connectedCallback)
this.feeRate.value = manualRate;
return this.feeRate.value
}
}
/**
* Browser calls this method when the element is added to the document
* (can be called many times if an element is repeatedly added/removed)
*/
connectedCallback() {
this.feeRate.addEventListener("change", (event) => {
this.manualFeeUpdated()
})
// fetch the fees which looks like ...
this.fetchFees()
// this: {"result": {"fastestFee": 8, "halfHourFee": 8, "hourFee": 8, "minimumFee": 1}, "error_messages": []}
this.showFeeOption(this.feeOptionPreset)
if (this.rbfEnabled) {
this.rbfLabel.style.display = "block"
if (this.rbfCheckboxChecked) {
this.rbfCheckbox.checked = true
}
}
}
/** Will get executed after fee initialisation
*/
initWithFees() {
this.feesSlider.min = this.fees["minimumFee"]
this.feesSlider.max = this.fees["fastestFee"] * 1.4
// Attributes are strings not numbers
let sliderMin = parseInt(this.feesSlider.min)
let sliderMax = parseInt(this.feesSlider.max)
let average = Math.floor((sliderMin + sliderMax) / 2)
this.feesSlider.value = average
this.feeRate.value = this.feesSlider.value
this.dynamicFeeUpdated() // otherwise no text next to estimated speed
}
showFeeOption(option) {
if (option == 'dynamic') {
this.fee_manual.style.display ='none'
this.fee_dynamic.style.display = 'block'
this.ld.feeOption.value = "dynamic"
this.fee_option_dynamic.checked = true
} else {
this.fee_manual.style.display ='block'
this.fee_dynamic.style.display = 'none'
this.ld.feeOption.value = "manual"
this.fee_option_manual.checked = true
}
}
// Transport shadowDOM value to lightDOM
manualFeeUpdated() {
this.ld.feeRate.value = this.feeRate.value
}
dynamicFeeUpdated() {
this.feeRateDynamicText.innerText = this.feesSlider.value;
this.feeRateDynamic.value = this.feesSlider.value;
this.ld.feeRateDynamic.value = this.feesSlider.value;
let minFee = this.fees["minimumFee"]
let hourFee = this.fees["hourFee"]
let halfHourFee = this.fees["halfHourFee"]
let fastestFee = this.fees["fastestFee"]
if (this.feesSlider.value <= minFee + ((hourFee - minFee) / 2)) {
this.fee_rate_speed_text.innerText = '{{ _("Very slow") }}';
} else if (this.feesSlider.value > minFee + ((hourFee - minFee) / 2) && this.feesSlider.value < hourFee) {
this.fee_rate_speed_text.innerText = '{{ _("Slow") }}';
} else if (this.feesSlider.value >= hourFee && this.feesSlider.value < halfHourFee) {
this.fee_rate_speed_text.innerText = '{{ _("Medium (1 hour)") }}';
} else if (this.feesSlider.value >= halfHourFee && this.feesSlider.value < fastestFee) {
this.fee_rate_speed_text.innerText = '{{ _("Fast (30 minutes)") }}';
} else if (this.feesSlider.value >= fastestFee && this.feesSlider.value < fastestFee * 1.2) {
this.fee_rate_speed_text.innerText = '{{ _("Very fast (10 minutes)") }}';
} else if (this.feesSlider.value >= fastestFee * 1.2) {
this.fee_rate_speed_text.innerText = '{{ _("Overpaid! (10 minutes)") }}';
} else {
console.log("Could not set fee_rate_speed_text")
}
}
toggleSubtractFrom() {
if (this.subtract.checked && (amounts.length > 1 || !document.getElementById('ui-radio-btn').checked)) {
this.subtractFrom.style.display = 'block';
document.getElementById('coin-selection-row').style['margin-top'] = '90px';
} else {
this.subtractFrom.style.display = 'none';
document.getElementById('coin-selection-row').style['margin-top'] = '30px';
}
this.ld.subtract.checked = this.subtract.checked
}
/**
* Fetches assetBalance from the Specter API
*/
async fetchFees(wallet_alias) {
let url = `{{ url_for('wallets_endpoint_api.fees') }}`
var formData = new FormData();
try {
const response = await fetch(
url,
{
method: 'GET'
}
);
if(response.status != 200){
showError(await response.text());
console.log("Error while fetching fees")
return {"result": {"fastestFee": 8, "halfHourFee": 8, "hourFee": 8, "minimumFee": 1}, "error_messages": ["Couldn't fetch fees from server. Replaced with assumptions"]};
}
const fees = await response.json();
this.fees = fees["result"]
this.initWithFees()
} catch(e) {
console.log(e);
showError(`{{ _("Failed to fetch fees") }}: ${e}`);
}
}
}
customElements.define('fee-selection', FeeSelection);
</script>

View file

@ -1,4 +1,4 @@
<tool-tip title="Merkle Proof Validation" style="float: right;">
<tool-tip title="Merkle Proof Validation">
{{ _('Specter-Desktop validates <a href="https://github.com/bitcoin/bips/blob/master/bip-0037.mediawiki" target="_blank">BIP 37</a> Merkle Proofs from your full node <strong>guaranteeing</strong> that transactions displayed as confirmed have been included in the specified block hash.') }}<br><br>
{{ _("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) <strong>and you trust specter-desktop isn't lying to you</strong>, then you can be sure this transaction exists in their blockchain as well.") }}"

View file

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

View file

@ -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() }}');

View file

@ -18,23 +18,29 @@
</style>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles.css') }}">
<tr class="tx-row">
<td>
<td id="column-callbackaction" class="callbackaction">
<input class="checkbox coin_select_checkbox" type="checkbox" name="coinselect" value="" >
</td>
<td id="column-category">
<img class="category" style="vertical-align: middle; margin-right: 10px;"/>
<img class="frozen-img hidden svg-white" style="vertical-align: middle; height: 28px;" src="{{ url_for('static', filename='img/snowflake.svg') }}"/>
</td>
<td class="tx scroll optional txid">
<td id="column-txid" class="tx scroll optional txid">
<span class="explorer-link"></span>
</td>
<td class="tx scroll optional address"></td>
<td><span class="amount"></span> <span class="amount-price note hidden">()</span></td>
<td class="time"></td>
<td>
<td id="column-label" class="tx optional address"></td>
<td id="column-amount">
<span class="amount"></span>
<span class="amount-price note hidden">()</span>
</td>
<td id="column-time" class="time"></td>
<td id="column-confirmations" >
<span class="confirmations"></span>
<button class="rbf btn optional hidden" style="width: 130px; float: right;" type="button">{{ _("Speed up") }}</button>
<button class="rbf-cancel danger btn optional hidden" style="width: 130px; float: right; margin-right: 10px;" type="button">{{ _("Cancel transaction") }}</button>
</td>
<td class="hidden optional blockhash"></td>
<td><input class="select-tx-value" type="hidden" value=""><img style="vertical-align: middle;" class="select-tx-img" src="{{ url_for('static', filename='img/checkbox-untick.svg') }}" width="25px"></tool-tip></td>
<td id="column-action"><input class="select-tx-value" type="hidden" value=""><img style="vertical-align: middle;" class="select-tx-img" src="{{ url_for('static', filename='img/checkbox-untick.svg') }}" width="25px"></tool-tip></td>
</tr>
</template>
@ -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) {

View file

@ -92,7 +92,7 @@
height: 38px;
}
</style>
<nav class="row">
<nav class="switcher row">
<button
type="button"
class="txlist-view-btn btn radio left">
@ -154,38 +154,45 @@
<table class="tx-table">
<thead>
<tr>
<th value="category" class="category-header"><div class="category-arrow"></div></th>
<th value="txid" class="optional txid-header">TxID<div class="txid-arrow"></div></th>
<th value="label" class="optional label-header">{{ _("Label") }}<div class="label-arrow"></div></th>
<th value="amount" class="amount-header">{{ _("Amount") }}<div class="amount-arrow"></div></th>
<th value="time" class="time-header">{{ _("Time") }}<div class="time-arrow up-arrow"></div></th>
<th value="confirmations" class="confirmations-header">{{ _("Confirmations") }}<div class="confirmations-arrow"></div></th>
<th value="blockhash" class="optional hidden blockhash-header">
{% include "includes/merkletooltip.html" %}
<th value="callbackaction" class="column-callbackaction customaction-header"></th>
<th value="category" class="column-category category-header"><div class="category-arrow"></div></th>
<th value="txid" class="column-txid optional txid-header">TxID<div class="txid-arrow"></div></th>
<th value="label" class="column-label optional label-header">{{ _("Label") }}<div class="label-arrow"></div></th>
<th value="amount" class="column-amount amount-header">{{ _("Amount") }}<div class="amount-arrow"></div></th>
<th value="time" class="column-time time-header">{{ _("Time") }}<div class="time-arrow up-arrow"></div></th>
<th value="confirmations" class="column-confirmations confirmations-header">{{ _("Confirmations") }}<div class="confirmations-arrow"></div></th>
<th value="blockhash" class="column-blockhash optional hidden blockhash-header">
{{ _("Block Hash") }}
{% include "includes/merkletooltip.html" %}
</th>
<th class="column-action" style="width: 50px; padding: 10px;">
<button type="button" class="export-btn btn" style="width: 50px;">
{{ _("Export") }}
</button>
</th>
<th style="width: 50px; padding: 10px;"><button type="button" class="export-btn btn" style="width: 50px;">{{ _("Export") }}</button></th>
</tr>
</thead>
</thead>
<tr class="empty">
<td></td>
<td>{{ _("Fetching transactions...") }}</td>
<td class="optional"></td>
<td class="optional"></td>
<td></td>
<td></td>
<td class="optional hidden blockhash-empty"></td>
<td></td>
<td class="column-callbackaction"></td>
<td class="column-category"></td>
<td class="column-txid">{{ _("Fetching transactions...") }}</td>
<td class="column-label optional"></td>
<td class="column-amount optional"></td>
<td class="column-time" ></td>
<td class="column-confirmations"></td>
<td class="column-blockhash optional hidden blockhash-empty"></td>
<td class="column-action"></td>
</tr>
<tr class="summary-row hidden">
<td></td>
<td class="summary-tx-count"></td>
<td class="optional"></td>
<td class="summary-amount"></td>
<td class="optional"></td>
<td></td>
<td class="optional hidden blockhash-summary"></td>
<td></td>
<td class="column-callbackaction"></td>
<td class="column-category" ></td>
<td class="column-txid summary-tx-count"></td>
<td class="column-label optional"></td>
<td class="column-amount summary-amount"></td>
<td class="column-time optional"></td>
<td class="column-confirmations"> </td>
<td class="column-blockhash optional hidden blockhash-summary"></td>
<td class="column-action"></td>
</tr>
<tbody class="tx-tbody">
</tbody>
@ -213,6 +220,7 @@
* - Showing amounts in either BTC or sats (set attribute `btc-unit` to either "btc" or "sat")
* - Showing prices next to the amounts (set attribute `price` to the BTC price and symbol to the symbol of the currency you're pricing at)
* - Showing validated blockhash column (set attribute `blockhash` to either "true" or "false")
* - Hide columns by hide-columns="category time"
*/
class TxTableElement extends HTMLElement {
constructor() {
@ -279,6 +287,9 @@
this.freezeTxForm = clone.querySelector(".freeze-tx-form");
this.freezeTxBtn = clone.querySelector(".freeze-tx-btn");
this.selectedRows = [];
// CustomAction Preselection default
this.selectedCoins = []
this.txlistViewBtn.onclick = () => {
if (!this.txlistViewBtn.classList.contains("checked")) {
@ -425,7 +436,53 @@
}
static get observedAttributes() {
return ['blockhash', 'btc-unit', 'price', 'symbol', 'type', 'hide-sensitive-info', 'wallet'];
return ['blockhash', 'btc-unit', 'price', 'symbol', 'type', 'hide-sensitive-info', 'wallet', 'selected-coins'];
}
connectedCallback() {
// Hide columns
this.hideColumns = this.getAttribute("hide-columns") ?? "callbackaction"
if (this.hideColumns && this.hideColumns != "null") {
this.hideColumns.split(" ").forEach((column_name) => {
if (column_name !== null) {
this.shadowRoot.querySelectorAll(".column-"+ column_name).forEach((element) => {
element.remove()
})
}
})
}
// Hide the switcher
this.hideSwitcher = this.getAttribute("hide-switcher")
if (this.hideSwitcher && this.hideSwitcher.toLowerCase() == "true") {
this.shadowRoot.querySelector(".switcher").classList.add('hidden')
}
}
getSelectedTxs() {
var selectedTxs = []
this.shadowRoot.querySelectorAll(".txrowitem").forEach((item) => {
if (item.isSelected()) {
selectedTxs.push(item.tx)
}
})
return selectedTxs
}
unselect_all() {
this.shadowRoot.querySelectorAll(".txrowitem").forEach((item) => {
item.unselect()
})
}
select_coins(selected_coins) {
this.shadowRoot.querySelectorAll(".txrowitem").forEach((item) => {
selected_coins.forEach((selected_coin) => {
selected_coin.split(/,/)
if (item.isSelected()) {
}
})
})
}
/**
@ -439,40 +496,74 @@
* - wallet: The wallet alias (null to get all wallets combined)
*/
attributeChangedCallback(attrName, oldValue, newValue) {
if (this.getAttribute('blockhash') == 'true' && this.getAttribute('type') == "txlist" && this.getAttribute('wallet')) {
this.blockhashHeader.classList.remove('hidden');
this.blockhashEmpty.classList.remove('hidden');
this.blockhashSummary.classList.remove('hidden');
} else {
this.blockhashHeader.classList.add('hidden');
this.blockhashEmpty.classList.add('hidden');
this.blockhashSummary.classList.add('hidden');
}
if (
this.blockhash != this.getAttribute('blockhash') ||
this.btcUnit != this.getAttribute('btc-unit') ||
this.price != this.getAttribute('price') ||
this.symbol != this.getAttribute('symbol') ||
this.listType != this.getAttribute('type') ||
this.hideSensitiveInfo != this.getAttribute('hide-sensitive-info') ||
this.wallet != this.getAttribute('wallet')
) {
this.blockhash = this.getAttribute('blockhash');
this.btcUnit = this.getAttribute('btc-unit');
this.price = this.getAttribute('price');
this.symbol = this.getAttribute('symbol');
this.listType = this.getAttribute('type');
this.hideSensitiveInfo = this.getAttribute('hide-sensitive-info') == 'true';
this.wallet = this.getAttribute('wallet');
if (!this.listType) {
return
if (attrName != "selected-coins") {
if (this.getAttribute('blockhash') == 'true' && this.getAttribute('type') == "txlist" && this.getAttribute('wallet')) {
this.blockhashHeader.classList.remove('hidden');
this.blockhashEmpty.classList.remove('hidden');
this.blockhashSummary.classList.remove('hidden');
} else {
this.blockhashHeader.classList.add('hidden');
this.blockhashEmpty.classList.add('hidden');
this.blockhashSummary.classList.add('hidden');
}
this.fetchTxItems();
if (
this.blockhash != this.getAttribute('blockhash') ||
this.btcUnit != this.getAttribute('btc-unit') ||
this.price != this.getAttribute('price') ||
this.symbol != this.getAttribute('symbol') ||
this.listType != this.getAttribute('type') ||
this.hideSensitiveInfo != this.getAttribute('hide-sensitive-info') ||
this.wallet != this.getAttribute('wallet')
) {
this.blockhash = this.getAttribute('blockhash');
this.btcUnit = this.getAttribute('btc-unit');
this.price = this.getAttribute('price');
this.symbol = this.getAttribute('symbol');
this.listType = this.getAttribute('type');
this.hideSensitiveInfo = this.getAttribute('hide-sensitive-info') == 'true';
this.wallet = this.getAttribute('wallet');
if (!this.listType) {
return
}
this.fetchTxItems();
}
}
if (attrName == "selected-coins" && newValue != undefined && newValue != "undefined") {
let selectedCoins = JSON.parse(newValue)
this.selectedCoins = selectedCoins.map((txid_vout) => {
// For easier comparison, we're removing the vout
let txid = txid_vout.split(/,/)[0]
return txid
})
}
}
/**
* Fetches txlist from the Specter API and loads the result into TxRowElement
* searches this.selectedCoin for a transactionId and removes that if found and returns True in that case.
* Explanation: An initial pre-ticking the checkboxes is not that easy as the state of the component is
* created dynamically. This also involves the preselected checkboxes which get set by setAttribute("selected-coins",...)
* In order to solve this, this.selectedCoins is an array of txids which nedd to be checked and they get "consumed" when the
* the check is positive.
*/
checkForSelectedCoin(txid) {
if (this.selectedCoins != []) {
let filtered = this.selectedCoins.filter((value, index, arr) => {
return value != txid
})
if (filtered.length == this.selectedCoins.length) {
return false
} else {
console.log("Found txid"+txid)
this.selectedCoins = filtered
return true
}
}
return false
}
/**
* Fetches txlist from the Specter API and loads the result into TxRowElements
*/
async fetchTxItems() {
this.callId++;
@ -497,20 +588,20 @@
switch (this.listType) {
case "txlist":
if (this.wallet) {
this.export.href = `{{ url_for('wallets_endpoint.tx_history_csv', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
url = `{{ url_for('wallets_endpoint.txlist', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
this.export.href = `{{ url_for('wallets_endpoint_api.tx_history_csv', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
url = `{{ url_for('wallets_endpoint_api.txlist', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
} else {
this.export.href = `{{ url_for('wallets_endpoint.wallet_overview_txs_csv') }}`;
url = `{{ url_for('wallets_endpoint.wallets_overview_txlist') }}`;
this.export.href = `{{ url_for('wallets_endpoint_api.wallet_overview_txs_csv') }}`;
url = `{{ url_for('wallets_endpoint_api.wallets_overview_txlist') }}`;
}
break;
case "utxo":
if (this.wallet) {
this.export.href = `{{ url_for('wallets_endpoint.utxo_csv', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
url = `{{ url_for('wallets_endpoint.utxo_list', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
this.export.href = `{{ url_for('wallets_endpoint_api.utxo_csv', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
url = `{{ url_for('wallets_endpoint_api.utxo_list', wallet_alias='WALLET_ALIAS') }}`.replace("WALLET_ALIAS", this.wallet);
} else {
this.export.href = `{{ url_for('wallets_endpoint.wallet_overview_utxo_csv') }}`;
url = `{{ url_for('wallets_endpoint.wallets_overview_utxo_list') }}`;
this.export.href = `{{ url_for('wallets_endpoint_api.wallet_overview_utxo_csv') }}`;
url = `{{ url_for('wallets_endpoint_api.wallets_overview_utxo_list') }}`;
}
break
default:
@ -559,7 +650,7 @@
let summaryPrice = 0;
for (let tx of txlist) {
let txRow = document.createRange().createContextualFragment(`
<tx-row
<tx-row class="txrowitem"
data-btc-unit="${this.btcUnit ? this.btcUnit : 'btc'}"
data-price="${this.price ? this.price : 0}"
data-symbol="${this.symbol ? this.symbol : ''}"
@ -567,7 +658,8 @@
data-wallet="${(this.wallet ? this.wallet : tx.wallet_alias)}"
data-show-blockhash="${showBlockhash}"
data-mode="${this.getAttribute('type') + (this.wallet ? "" : "-overview")}"
data-hide-sensitive-info="${this.hideSensitiveInfo}">
data-hide-sensitive-info="${this.hideSensitiveInfo}"
hide-columns="${this.hideColumns}">
</tx-row>
`)
this.tbody.append(txRow);
@ -629,7 +721,18 @@
this.selectedRows = [];
this.selectedRowsActionBox.classList.add('hidden');
// if preselectedCoins are there, we need to dispatch an event
let dispatchLater = false
for (let txRow of this.shadowRoot.querySelectorAll('tx-row')) {
// Preselect if necessary
if (this.checkForSelectedCoin(txRow.tx.txid)) {
txRow.select()
dispatchLater = true
}
txRow.addEventListener('txRowCustomSelected', (e) => {
this.fireCustomSelectedEvent()
})
txRow.addEventListener('txRowSelected', e=>{
let selectedRow = e.target;
if (e.detail.selected) {
@ -648,6 +751,9 @@
}
});
}
if (dispatchLater) {
this.fireCustomSelectedEvent()
}
}
return
};
@ -657,9 +763,20 @@
showError(`{{ _("Failed to fetch transactions list.") }}: ${e}`);
}
}
/**
* If this event is fired, then some row got ticked/unticked in the custom-selection-column
*/
fireCustomSelectedEvent() {
let event = new CustomEvent('CustomSelected', { detail: {
txs: this.getSelectedTxs()
} }
);
this.dispatchEvent(event);
}
}
/**
* Shows a <tx-data> element popup for specified txid.
* @param btcUnit - Bitcoin unit to display amounts with. Either "btc" or "sat"

View file

@ -109,6 +109,7 @@
{{ _("Some optional Specter functionality, like rescanning UTXO on a pruned node or getting the Bitcoin price, might make calls to external APIs and services.") }}<br><br>
{{ _("Toggle this on to ensure Specter routes all these external calls over Tor proxy.") }}<br><br>
{{ _("Note: Some external sources may stop working if they block Tor call.") }}
{{ _("This will have a significant performance impact. Specter will be much slower!") }}
</tool-tip>
<label class="switch" style="float: right; margin-top: 30px;">
<input type="checkbox" id="only-tor" name="only_tor" {% if only_tor %}checked{% endif %}>

View file

@ -18,7 +18,7 @@
btc-unit="{{ specter.unit }}"
hide-sensitive-info="{{ specter.hide_sensitive_info | lower }}"
type="receive"
wallet="{{ wallet.alias }}"></addr-table-table>
wallet="{{ wallet.alias }}"></addresses-table>
</div>
<div id="address-popup" class="hidden"></div>
{% endblock %}

View file

@ -1,61 +0,0 @@
<div>
{% from 'wallet/send/new/components/coin_selection_table.jinja' import coin_selection_table %}
{{ coin_selection_table(wallet_utxo + rbf_utxo, specter.explorer, selected_coins, rbf_tx_id) }}
<br>
</div>
<script>
var coinSelectAmount = 0.0;
function toggleExpand() {
if (isCoinSelectionActive()) {
setVisibility('coin_selection_table', 'none');
} else {
setVisibility('coin_selection_table', 'block');
let coins = document.getElementsByClassName('coin_select_checkbox');
// unselect all choices
for(var i = 0; i < coins.length; i++){
coins[i].checked = false;
}
}
validateForm();
}
function updateCoinSelect(coin, amount) {
let coinAmount = parseFloat(amount);
if (coin.checked) {
coinSelectAmount += coinAmount;
} else {
coinSelectAmount -= coinAmount;
}
coinSelectAmount = parseFloat(coinSelectAmount.toFixed(8));
validateForm();
}
function isCoinSelectionActive() {
return document.getElementById("coin_selection_table").style.display !== 'none';
}
function getSpendableAmount(unit) {
let spendableAmount;
if (!isCoinSelectionActive()) {
if(unit == 'btc' || unit == 'sat'){
spendableAmount = '{{ wallet.full_available_balance | btcamount }}';
}else{
if(unit in assetBalances){
return assetBalances[unit].balance;
}else{
return 0;
}
}
} else {
spendableAmount = coinSelectAmount;
}
return (unit == 'sat' ? spendableAmount * 100000000 : spendableAmount);
}
function shouldSelectMoreCoins(unit, amount) {
return (unit == 'sat' ? amount / 100000000 : amount) > coinSelectAmount && isCoinSelectionActive()
}
</script>

View file

@ -1,32 +0,0 @@
{#
coin_selection_item - UTXO coin selection table row.
Parameters:
- txid: The transaction ID.
- amount: The transaction amount.
- address: The address associated with the transaction.
- label: The label of `address` (equal to `address` if no label exists).
- explorer: explorer url.
- selected: The coin was selected previously
#}
{% macro coin_selection_item(txid, vout, amount, address, label, explorer, selected, assetlabel="") -%}
{% from 'wallet/components/explorer_link.jinja' import explorer_link %}
<tr>
<td>
<input id="coin_{{ [txid, vout] | join(', ') }}" class="checkbox coin_select_checkbox" type="checkbox" name="coinselect" value="{{ [txid, vout] | join(', ') }}" onchange='updateCoinSelect(this, "{{ amount }}")' {% if selected %}checked{% endif %}>
{% if selected %}
<script>
document.addEventListener("DOMContentLoaded", function(){
updateCoinSelect(document.getElementById("coin_{{ [txid, vout] | join(', ') }}"), "{{ amount }}")
});
</script>
{% endif %}
</td>
<td class="tx scroll">
{{ explorer_link('tx', txid, txid, explorer) }}
</td>
<td class="tx scroll">
<address-label data-address="{{ address }}" data-label="{{ label if label else address }}" data-wallet="{{ wallet_alias }}"></address-label>
</td>
<td>{{ amount | btcunitamount }} {{assetlabel}}</td>
</tr>
{%- endmacro %}

View file

@ -1,34 +0,0 @@
{% from 'wallet/send/new/components/coin_selection_item.jinja' import coin_selection_item %}
{#
coin_selection_table - List of the wallet UTXO for coin selection
Parameters:
- unspents: List of wallet UTXOs
- explorer: explorer link
- selected_coins: List of UXTOs previously selected (if any)
- rbf_tx_id: tx id to skip because the editor is used to edit it in RBF (empty string if not RBF editing)
#}
{% macro coin_selection_table(unspents, explorer, selected_coins, rbf_tx_id) -%}
<table style="table-layout: fixed; display: {% if selected_coins %}block{% else %}none{% endif %}; max-width:98%;" id="coin_selection_table">
<thead>
<tr>
<th></th><th>{{ _("TxID") }}</th><th>{{ _("Address") }}</th><th>{{ _("Amount") }}</th>
</tr>
</thead>
<tbody>
{% for tx in unspents if tx['txid'] != rbf_tx_id %}
{% from 'wallet/send/new/components/coin_selection_item.jinja' import coin_selection_item %}
{{ coin_selection_item(
tx['txid'],
tx['vout'],
tx['amount'],
tx['address'],
tx['label'],
explorer,
"{}, {}".format(tx['txid'], tx['vout']) in selected_coins,
tx.get("assetlabel",""),
) }}
{% endfor %}
</tbody>
</table>
{%- endmacro %}

View file

@ -1,93 +0,0 @@
{% set fee_options_dynamic = "dynamic" in fee_options %}
<div class="fee_container">
<div>
<label><input type="checkbox" class="inline" name="subtract" id="subtract" {% if subtract %}checked{% endif %} onchange="toggleSubtractFrom(this)"> {{ _("Subtract fees from amount") }}</label>
<div class="tool-tip" style="text-align: center;">
<i class="tool-tip__icon">i</i>
<p class="tool-tip__info">
<span class="info">
<span class="info__title">{{ _("Subtract fees from amount") }}<br><br></span>
{{ _("If checked, the transaction fees will be paid off from the transaction amount.") }}<br><br>
{{ _("Otherwise, the fees will be paid as an added cost in addition to the amount sent.") }}
</span>
</p>
</div>
</div>
<span id="subtract_from" class="hidden"><br>{{ _("Subtract from recipient number:") }} <input id="subtract_from_input" name="subtract_from" type="number" min="1" value="{{ subtract_from }}" step="1" style="width: 80px; min-width: 80px;"><br></span>
<br>
<div>
{{ _("Fees:") }}
<label><input type="radio" class="inline" style="margin: 0 10px 0 20px;" id="fee_options_dynamic" name="fee_options" value="dynamic" onclick="showFeeOption(this)" {% if fee_options_dynamic %}checked{% endif %}>{{ _("dynamic") }}</label>
<label><input type="radio" class="inline" style="margin: 0 10px 0 20px" id="fee_option_manual" name="fee_options" value="manual" onclick="showFeeOption(this);" {% if not fee_options_dynamic %}checked{% endif %}>{{ _("manual") }}</label>
</div>
<br>
<div id = "fee_manual" style="display: {% if fee_options_dynamic %}none{% else %}block{% endif %}">
{{ _("Fee rate:") }}<br>
{% set min_fee = 0.1 if specter.is_liquid else 1 %}
<input type="number" class="fee_rate" name="fee_rate" id="fee_rate" min="{{min_fee}}" step="any" autocomplete="off" {% if not fee_options_dynamic %}value="{{ fee_rate }}"{% endif %}> sat/vbyte
<div class="note">
{{ _("leave blank to set automatically, {} sat/vbyte is the minimal fee rate.".format(min_fee)) }}
</div>
</div>
<div id ="fee_dynamic" style="display: {% if fee_options_dynamic %}block{% else %}none{% endif %}">
<div id="blocks"></div>
<input type="range" style="width: 12em" min="{{fee_estimation_data['minimumFee']}}" max="{{fee_estimation_data['fastestFee'] * 1.4}}" value="{{ fee_estimation }}" step="1" id="fees_slider" oninput="dynamicFeeUpdated()">
<input type="hidden" id="fee_rate_dynamic" name="fee_rate_dynamic" value="0">
<div>
{{ _("Estimated speed:") }} <span id="fee_rate_speed_text"></span>
<br>
<span class="note">({{ _("Fee rate:") }} <span id="fee_rate_dynamic_text"></span> sat/vbyte)</span>
</div>
</div>
<br>
<label {% if specter.is_liquid %}style="display: none"{% endif %}><input type="checkbox" class="inline" name="rbf" id="rbf" {% if rbf %}checked{% endif %}> {{ _("RBF Enabled") }}</label>
</div>
<script>
document.addEventListener("DOMContentLoaded", dynamicFeeUpdated);
function showFeeOption(myRadio) {
if (myRadio.value == 'dynamic') {
setVisibility('fee_manual', 'none');
setVisibility('fee_dynamic', 'block');
} else {
setVisibility('fee_manual', 'block');
setVisibility('fee_dynamic', 'none');
}
}
function dynamicFeeUpdated() {
let feesSlider = document.getElementById('fees_slider');
document.getElementById('fee_rate_dynamic_text').innerText = feesSlider.value;
document.getElementById('fee_rate_dynamic').value = feesSlider.value;
let minFee = parseInt("{{fee_estimation_data['minimumFee']}}");
let hourFee = parseInt("{{fee_estimation_data['hourFee']}}");
let halfHourFee = parseInt("{{fee_estimation_data['halfHourFee']}}");
let fastestFee = parseInt("{{fee_estimation_data['fastestFee']}}");
if (feesSlider.value <= minFee + ((hourFee - minFee) / 2)) {
document.getElementById('fee_rate_speed_text').innerText = '{{ _("Very slow") }}';
} else if (feesSlider.value > minFee + ((hourFee - minFee) / 2) && feesSlider.value < hourFee) {
document.getElementById('fee_rate_speed_text').innerText = '{{ _("Slow") }}';
} else if (feesSlider.value >= hourFee && feesSlider.value < halfHourFee) {
document.getElementById('fee_rate_speed_text').innerText = '{{ _("Medium (1 hour)") }}';
} else if (feesSlider.value >= halfHourFee && feesSlider.value < fastestFee) {
document.getElementById('fee_rate_speed_text').innerText = '{{ _("Fast (30 minutes)") }}';
} else if (feesSlider.value >= fastestFee && feesSlider.value < fastestFee * 1.2) {
document.getElementById('fee_rate_speed_text').innerText = '{{ _("Very fast (10 minutes)") }}';
} else if (feesSlider.value >= fastestFee * 1.2) {
document.getElementById('fee_rate_speed_text').innerText = '{{ _("Overpaid! (10 minutes)") }}';
}
}
function toggleSubtractFrom(checkbox) {
if (checkbox.checked && (amounts.length > 1 || !document.getElementById('ui-radio-btn').checked)) {
document.getElementById('subtract_from').style.display = 'block';
document.getElementById('coin-selection-row').style['margin-top'] = '90px';
} else {
document.getElementById('subtract_from').style.display = 'none';
document.getElementById('coin-selection-row').style['margin-top'] = '30px';
}
}
</script>

View file

@ -27,7 +27,7 @@
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="rbf_tx_id" value="{{ rbf_tx_id }}"/>
<h1 class="padded">{{ _("Create Transaction") }}</h1>
<p class="center">{{ _("Available Funds:") }} {{wallet.full_available_balance | btcunitamount}}
<p class="center">{{ _("Available funds:") }} {{wallet.full_available_balance | btcunitamount}}
{% if specter.unit == 'sat' %}
sats
{% else %}
@ -60,7 +60,7 @@
</span>
</p>
</div>
<p style="display: inline; margin: auto; text-align: center;">&nbsp;{{ _("You have waiting") }} {{wallet.locked_amount | btcunitamount}} {% if specter.unit == 'sat' %}sats{% else %}{% if specter.is_testnet %}t{%endif%}BTC{% endif %} {{ _("still in unsigned transactions.") }}</p>
<p style="display: inline; margin: auto; text-align: center;">&nbsp;{{ _("You have") }} {{wallet.locked_amount | btcunitamount}} {% if specter.unit == 'sat' %}sats{% else %}{% if specter.is_testnet %}t{%endif%}BTC{% endif %} {{ _("waiting in unsigned transactions.") }}</p>
<br>
<br>
</div>
@ -89,23 +89,31 @@
<label><input type="radio" class="inline" style="margin: 0 10px 0 20px;" name="ui_option" value="ui" onclick="toggleSendUIType(this)" {% if ui_option == 'ui' %}checked{% endif %} id="ui-radio-btn">UI</label>
<label><input type="radio" class="inline" style="margin: 0 10px 0 20px;" name="ui_option" value="text" onclick="toggleSendUIType(this);" {% if ui_option != 'ui' %}checked{% endif %}>text</label>
</div><br>
{% with subtract=subtract, subtract_from=subtract_from, fee_options=fee_options, fee_rate=fee_rate, rbf=rbf %}
{% include "wallet/send/new/components/fee_selection.jinja" %}
{% endwith %}
{% include "includes/fee-selection.html" %}
<fee-selection id="fee-selection-component"></fee-selection>
<br>
<br>
<br>
<div class="row break-row-mobile" id="coin-selection-row" style="margin-top: 30px;">
<button id="add-recipient" style="width: 200px; height: 38px;" type="button" class="btn" onclick="addRecipient('', 0, 'btc', '')"><svg width="20" height="20" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> {{ _("Add recipient") }} </button>
{% if wallet.utxo | length > 0 %}
{% if not specter.is_liquid %}
<button style="width: 200px; height: 38px; margin-left: 10px;" id="coinselect" type="button" class="btn" onclick="toggleExpand()">{{ _("Select coins") }}<span class="optional">&nbsp;{{ _("manually") }}</span></button>
{% endif %}
{% endif %}
</div><br>
{% with selected_coins=selected_coins %}
{% include "wallet/send/new/components/coin_selection.jinja" %}
{% endwith %}
</div>
{% if wallet.utxo | length > 0 %}
{% include "includes/tx-row.html" %}
{% include "includes/tx-data.html" %}
{% include "includes/explorer-link.html" %}
{% include "includes/tx-table.html" %}
{% include "includes/coin_selection.html" %}
<coin-selection
id="coinselection-webcomponent"
wallet-alias="{{wallet.alias}}"
spendable-amount='{{ wallet.full_available_balance | btcamount }}'
is-liquid="{{specter.is_liquid}}"
selected-coins="{{ selected_coins }}">
</coin-selection>
{% endif %}
<button onclick="validateForm(true)" type="button" name="action" value="createpsbt" id="create_psbt_btn" class="btn centered" style="margin-top: 20px;">{{ _('Create <span class="optional">&nbsp;unsigned&nbsp;</span>transaction') }}</button>
</div>
</form>
@ -142,6 +150,13 @@
},
{% endfor %}
};
var spendableAmount = {{ wallet.full_available_balance | btcamount }}
var coinselectionWebcomponent = document.getElementById('coinselection-webcomponent');
console.log("Setting attribute")
coinselectionWebcomponent.assetBalances = assetBalances
coinselectionWebcomponent.addEventListener("change", (event) => {
validateForm()
})
function addRecipient(addr, amount, amount_unit, label) {
let i = amounts.length;
@ -242,13 +257,9 @@
});
if (amounts.length == 1) {
document.getElementById('remove-recipient').style.display = 'none';
document.getElementById('subtract_from').style.display = 'none';
document.getElementById('coin-selection-row').style['margin-top'] = '30px';
} else {
document.getElementById('remove-recipient').style.display = 'block';
if (document.getElementById('subtract').checked) {
document.getElementById('subtract_from').style.display = 'block';
}
document.getElementById('coin-selection-row').style['margin-top'] = '90px';
}
calculateConvertedUnit(i);
@ -370,7 +381,7 @@
}
}
}
let maxAmount = (getSpendableAmount(units[i]) - (units[i] == 'sat' ? othersAmount * 1e8 : othersAmount));
let maxAmount = (coinselectionWebcomponent.getSpendableAmount(units[i]) - (units[i] == 'sat' ? othersAmount * 1e8 : othersAmount));
if (units[i] == 'sat') {
maxAmount = Math.round(maxAmount);
} else {
@ -392,10 +403,13 @@
.innerHTML = `{{ _("Amount entered is invalid!") }}`;
return false;
}
if (i) {
let amountInput = document.getElementById('amount_' + i);
amountInput.max = getSpendableAmount(unit);
if (coinselectionWebcomponent != null) {
amountInput.max = coinselectionWebcomponent.getSpendableAmount(unit);
} else {
amountInput.max = spendableAmount
}
}
if (!amount && !allowZero) {
return false;
@ -405,10 +419,7 @@
document.getElementById('amount_errors_container')
.innerHTML = `{{ _("You cannot send more than") }} ${assetBalances[unit].balance} ${assetBalances[unit].label}!`;
return false;
} else if (shouldSelectMoreCoins(unit, amount)) {
setVisibility('amount_errors_container', 'block');
document.getElementById('amount_errors_container')
.innerHTML = `{{ _("You need to select more coins to match your amount!") }}`;
} else if (coinselectionWebcomponent.shouldSelectMoreCoins(unit, amount)) {
return false;
} else if (isInvalidValue(unit, amount)) {
setVisibility('amount_errors_container', 'block');
@ -454,6 +465,11 @@
if (document.getElementById("amount_" + i).value == '') {
amount = 0;
}
// Check amount when using coin selection
if (coinselectionWebcomponent.shouldSelectMoreCoins(unit, amount) && submitted) {
console.log("We are in the coin selection amount check.");
showError(`{{ _("You need to select more coins!") }}`, 5000);
}
if (!validateAmount(unit, amount, i)) {
createPSBTButton.setAttribute('type', 'button');
return;
@ -507,9 +523,6 @@
if (advancedSettings.style.display === 'block') {
advancedSettings.style.display = 'none';
advancedButton.innerHTML = `{{ _("Advanced") }} &#9654;`;
if (isCoinSelectionActive()) {
toggleExpand();
}
} else {
advancedSettings.style.display = 'block';
advancedButton.innerHTML = `{{ _("Advanced") }} &#9660;`;
@ -548,21 +561,11 @@
return;
}
try {
let feeOptionManual = document.getElementById('fee_option_manual')
if (!feeOptionManual.checked) {
feeOptionManual.checked = true;
showFeeOption(feeOptionManual);
let manualRate = Math.round(document.getElementById('fee_rate_dynamic').value / FEE_RATE_STEP) * FEE_RATE_STEP;
if (manualRate < MIN_FEE_RATE) {
manualRate = MIN_FEE_RATE;
}
document.getElementById('fee_rate').value = manualRate;
}
let FeeSelectionComponent = document.getElementById('fee-selection-component')
FeeSelectionComponent.selectedFee() // Will also set the relevant input
var formData = new FormData(document.getElementById('send-form'));
formData.append("estimate_fee", true);
formData.append("action", "createpsbt");
let url="{{ url_for('wallets_endpoint.send_new', wallet_alias=wallet.alias) }}"
let url="{{ url_for('wallets_endpoint_api.estimate_fee', wallet_alias=wallet.alias) }}"
formData.append("estimate_fee", true)
const response = await fetch(
url,
{
@ -570,6 +573,11 @@
body: formData
}
);
if(response.status != 200){
showError(await response.text());
console.log("Error while fetching fees")
return
}
let result = await response.json();
console.log(result);
if (result.success) {
@ -590,7 +598,7 @@
}
} catch (e) {
console.log(e);
document.getElementById('calculated_tx_fee').innerText = `: {{ _("failed to calculate transaction fees") }}`;
document.getElementById('calculated_tx_fee').innerText = `: {{ _("Failed to calculate transaction fees. Perhaps you used an invalid Bitcoin address.") }}`; // TODO: Remove second part of the message until we have a better address validation here.
}
return -1;
}

View file

@ -347,7 +347,7 @@
}
async function combine(psbt1) {
let url = "{{ url_for('wallets_endpoint.combine', wallet_alias=wallet.alias) }}";
let url = "{{ url_for('wallets_endpoint_api.combine', wallet_alias=wallet.alias) }}";
var formData = new FormData();
formData.append("csrf_token", "{{ csrf_token() }}");
@ -415,7 +415,7 @@
async function broadcastLocal(tx) {
showNotification(`{{ _('Sending transaction...') }}`);
let url="{{ url_for('wallets_endpoint.broadcast', wallet_alias=wallet.alias) }}";
let url="{{ url_for('wallets_endpoint_api.broadcast', wallet_alias=wallet.alias) }}";
var formData = new FormData();
formData.append("csrf_token", "{{ csrf_token() }}");
@ -445,7 +445,7 @@
async function broadcastBlockExplorer(tx, explorer, useTor) {
showNotification(`{{ _('Sending transaction...') }}`, 0);
let url = "{{ url_for('wallets_endpoint.broadcast_blockexplorer', wallet_alias=wallet.alias) }}";
let url = "{{ url_for('wallets_endpoint_api.broadcast_blockexplorer', wallet_alias=wallet.alias) }}";
var formData = new FormData();

View file

@ -1,5 +1,6 @@
import logging
import re
from json import JSONEncoder
import requests
import urllib3
from requests.exceptions import ConnectionError
@ -35,6 +36,16 @@ class FeeEstimationResult:
self._error_messages.append(message)
class FeeEstimationResultEncoder(JSONEncoder):
def default(self, o):
raw = o.__dict__
raw["result"] = raw["_result"]
del raw["_result"]
raw["error_messages"] = raw["_error_messages"]
del raw["_error_messages"]
return raw
def get_fees(specter, config):
try:
return _get_fees(specter, config)

View file

@ -33,13 +33,18 @@ class PsbtCreator:
amounts in recipients_txt either "sats" or "btc"
* in both cases, the request_form also contains:
* "substract": optional (default: False), Boolean whether to substract the fee from the amounts, otherwise additional input gets created
* "substract_from": index on which address to substract the fee from
* "subtract_from": index on which address to substract the fee from
* fee_options:
* "dynamic": get the fee from "fee_rate_dynamic"
* "fee_rate": directly get the fee
* "rbf": "on" or "off" "boolean" whether to replace-by-fee
* "estimate_fee"
"""
# Good to have some values for error-reporting
self.ui_option = ui_option
self.request_form = request_form
self.request_json = request_json
if ui_option == "ui":
(
self.addresses,
@ -94,6 +99,19 @@ class PsbtCreator:
self.kwargs = PsbtCreator.kwargs_from_request_json(request_json)
if specter.is_liquid:
self.kwargs["assets"] = self.amount_units
self.validate_before_creation()
def validate_before_creation(self):
if self.kwargs["fee_rate"] == None:
if self.ui_option == "ui" or self.ui_option == "text":
additional_data = self.request_form
elif self.ui_option == "json":
additional_data = self.request_json
raise Exception(
f"Fee Rate could not be calculated and is now None. This is not supported right now and probably unintended anyway(ui_option = {self.ui_option}).\nrequest={additional_data}\nkwargs={self.kwargs}",
additional_data,
)
def create_psbt(self, wallet):
"""creates the PSBT via the wallet and modifies it for if substract is true
@ -246,14 +264,24 @@ class PsbtCreator:
# Who pays the fees?
subtract = bool(request_form.get("subtract", False))
subtract_from = int(request_form.get("subtract_from", 1))
fee_options = request_form.get("fee_options")
fee_option = request_form.get("fee_option")
fee_rate = None
if fee_options:
if "dynamic" in fee_options:
fee_rate = float(request_form.get("fee_rate_dynamic"))
if fee_option:
if "dynamic" in fee_option:
if request_form.get("fee_rate_dynamic"):
fee_rate = float(request_form.get("fee_rate_dynamic"))
else:
raise Exception(
"fee_option is dynamic but no fee_rate_dynamic given",
request_form,
)
else:
if request_form.get("fee_rate"):
fee_rate = float(request_form.get("fee_rate"))
else:
raise Exception(
"fee_option is manual but no fee_rate given", request_form
)
rbf = bool(request_form.get("rbf", False))
# workaround for making the tests work with a dict
if hasattr(request_form, "getlist"):

View file

@ -216,7 +216,7 @@ class WalletImporter:
logger.error("Exception while rescanning blockchain: %r" % e)
if potential_errors:
potential_errors = SpecterError(
potential_errors
str(potential_errors)
+ " and "
+ "Failed to perform rescan for wallet: %r" % e
)

View file

@ -1670,7 +1670,7 @@ class Wallet:
)
if sats_in_coins < total_sats:
raise SpecterError(
"Selected coins does not cover Full amount! Please select more coins!"
"Selected coins do not cover full amount. Please select more coins!"
)
extra_inputs = selected_coins

View file

@ -0,0 +1,34 @@
import logging
import pytest
import json
def test_fees(caplog, client):
"""The root of the app"""
caplog.set_level(logging.INFO)
caplog.set_level(logging.DEBUG, logger="cryptoadvance.specter")
login(client, "secret")
result = client.get("/wallets/fees")
assert result.status_code == 200
my_dict = json.loads(result.data)
assert my_dict["result"]["fastestFee"] == 1
assert my_dict["error_messages"] == []
logout(client)
# Ugly: Code duplication. Cannot import from other test_modules
def login(client, password):
"""login helper-function"""
result = client.post(
"auth/login", data=dict(password=password), follow_redirects=True
)
assert (
b"We could not check your password, maybe Bitcoin Core is not running or not configured?"
not in result.data
)
return result
def logout(client):
"""logout helper-method"""
return client.get("auth/logout", follow_redirects=True)

View file

@ -0,0 +1,23 @@
import json
from cryptoadvance.specter.util.fee_estimation import (
FeeEstimationResult,
FeeEstimationResultEncoder,
)
def test_FeeEstimationResultEncoder():
fee_estimation = FeeEstimationResult(
{
"fastestFee": 1,
"halfHourFee": 1,
"hourFee": 1,
"minimumFee": 1,
}
)
fee_estimation.add_error_message("some error message ")
fee_estimation.add_error_message("yet another one")
my_json = json.dumps(fee_estimation, cls=FeeEstimationResultEncoder)
my_dict = json.loads(my_json)
assert my_dict["result"]["fastestFee"] == 1
assert my_dict["error_messages"][1] == "yet another one"

View file

@ -29,7 +29,7 @@ def test_PsbtCreator_ui(caplog):
"amount_unit_1": "sat",
"amount_unit_text": "btc",
"subtract_from": "1",
"fee_options": "dynamic",
"fee_option": "dynamic",
"fee_rate": "",
"fee_rate_dynamic": "64",
"rbf": "on",
@ -73,7 +73,7 @@ def test_PsbtCreator_text(caplog):
request_form_data = {
"rbf_tx_id": "",
"subtract_from": "1",
"fee_options": "dynamic",
"fee_option": "dynamic",
"fee_rate": "",
"fee_rate_dynamic": "64",
"rbf": "on",

View file

@ -291,6 +291,16 @@ def test_wallet_labeling(bitcoin_regtest, devices_filled_data_folder, device_man
address_balance = wallet.fullbalance
assert len(wallet.full_utxo) == 20
print(wallet.full_utxo[4])
# Something like:
# { 'txid': 'fab823558781745179916b4bfdfd65b382bfc0e70e85188f1b9538604202f537',
# 'vout': 0, 'address': 'bcrt1qmlrraffw0evkjy2yrxmt263ksgfgv2gqhcddrt',
# 'label': 'Random label', 'scriptPubKey': '0014dfc63ea52e7e5969114419b6b56a368212862900',
# 'amount': 50.0, 'confirmations': 101, 'spendable': False, 'solvable': True,
# 'desc': "wpkh([08686ac6/48'/1'/0'/2'/0/0]02fa445808af849209038f422a22e335754fa07a2ece42fc483660606dcda3e0e9)#8q60z40m",
# 'safe': True, 'time': 1637091575, 'category': 'generate', 'locked': False
# }
new_address = wallet.getnewaddress()
wallet.setlabel(new_address, "")
wallet.rpc.generatetoaddress(20, new_address)

View file

@ -200,7 +200,9 @@ function start_specter {
specter_pid=$!
# Simulate slower machines with uncommenting this (-l 10 means using 10% cpu):
#cpulimit -p $specter_pid -l 10 -b
$(npm bin)/wait-on http://127.0.0.1:${PORT}
echo "--> Waiting for specter ..."
$(npm bin)/wait-on http://127.0.0.1:${PORT} && echo "--> Success"
}
function stop_specter {