mirror of
https://github.com/cryptoadvance/specter-desktop.git
synced 2026-08-13 12:33:29 +02:00
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:
parent
6f33ed14b6
commit
8742e9f75f
37 changed files with 2020 additions and 1312 deletions
|
|
@ -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
16
docs/performance.md
Normal 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.
|
||||
|
|
@ -13,6 +13,7 @@ nav:
|
|||
- Operating Guide:
|
||||
- connect-your-node.md
|
||||
- daemon.md
|
||||
- performance.md
|
||||
- reverse-proxy.md
|
||||
- self-signed-certificates.md
|
||||
- hwibridge.md
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
968
src/cryptoadvance/specter/server_endpoints/wallets_api.py
Normal file
968
src/cryptoadvance/specter/server_endpoints/wallets_api.py
Normal 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}
|
||||
|
|
@ -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() }}');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() }}');
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
201
src/cryptoadvance/specter/templates/includes/coin_selection.html
Normal file
201
src/cryptoadvance/specter/templates/includes/coin_selection.html
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<template id="coin-selection">
|
||||
<span class="toggle_coinselection" style="cursor: pointer;">{{ _("Coin selection") }} {% if show_advanced_settings %}▼{% else %}▶{% 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") }} ▶`;
|
||||
if (this.isCoinSelectionActive()) {
|
||||
toggleExpand();
|
||||
}
|
||||
} else {
|
||||
this.coinselectionDiv.style.display = 'block';
|
||||
this.toggler.innerHTML = `{{ _("Coin selection") }} ▼`;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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>
|
||||
309
src/cryptoadvance/specter/templates/includes/fee-selection.html
Normal file
309
src/cryptoadvance/specter/templates/includes/fee-selection.html
Normal 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>
|
||||
|
|
@ -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.") }}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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() }}');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 %}>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;"> {{ _("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;"> {{ _("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"> {{ _("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"> unsigned </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") }} ▶`;
|
||||
if (isCoinSelectionActive()) {
|
||||
toggleExpand();
|
||||
}
|
||||
} else {
|
||||
advancedSettings.style.display = 'block';
|
||||
advancedButton.innerHTML = `{{ _("Advanced") }} ▼`;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
34
tests/test_ep_wallet_api.py
Normal file
34
tests/test_ep_wallet_api.py
Normal 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)
|
||||
23
tests/test_util_fee_estimation.py
Normal file
23
tests/test_util_fee_estimation.py
Normal 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"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue