forked from bitcoinafterlife/bal-electrum-plugin
add bal/core
This commit is contained in:
21
bal/core/__init__.py
Normal file
21
bal/core/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
bal.core
|
||||||
|
========
|
||||||
|
|
||||||
|
Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||||
|
|
||||||
|
Everything in this sub-package MUST stay completely free of any GUI / Qt
|
||||||
|
imports. The rule of thumb is:
|
||||||
|
|
||||||
|
* ``bal.core`` -> "what the plugin does" (inheritance rules, building
|
||||||
|
and validating transactions, talking to
|
||||||
|
will-executor servers, persistence helpers).
|
||||||
|
* ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views).
|
||||||
|
|
||||||
|
Keeping the two apart is the main motivation behind this rewrite: the original
|
||||||
|
code mixed transaction-building logic and presentation inside a single
|
||||||
|
4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit.
|
||||||
|
|
||||||
|
No behaviour is changed with respect to the original plugin; the code has only
|
||||||
|
been reorganised and documented.
|
||||||
|
"""
|
||||||
BIN
bal/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
bal/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bal/core/__pycache__/heirs.cpython-311.pyc
Normal file
BIN
bal/core/__pycache__/heirs.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bal/core/__pycache__/plugin_base.cpython-311.pyc
Normal file
BIN
bal/core/__pycache__/plugin_base.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bal/core/__pycache__/util.cpython-311.pyc
Normal file
BIN
bal/core/__pycache__/util.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bal/core/__pycache__/will.cpython-311.pyc
Normal file
BIN
bal/core/__pycache__/will.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bal/core/__pycache__/willexecutors.cpython-311.pyc
Normal file
BIN
bal/core/__pycache__/willexecutors.cpython-311.pyc
Normal file
Binary file not shown.
850
bal/core/heirs.py
Normal file
850
bal/core/heirs.py
Normal file
@@ -0,0 +1,850 @@
|
|||||||
|
"""
|
||||||
|
bal.core.heirs
|
||||||
|
==============
|
||||||
|
|
||||||
|
Heir management and inheritance-transaction building.
|
||||||
|
|
||||||
|
This is the heart of the plugin's Bitcoin logic and the most delicate part of
|
||||||
|
the whole codebase, so the implementation below is kept byte-for-byte identical
|
||||||
|
to the original ``heirs.py``; only the dead commented-out imports were removed
|
||||||
|
and documentation was added.
|
||||||
|
|
||||||
|
An *heir* is stored as a small list addressed by the ``HEIR_*`` column
|
||||||
|
constants defined below. ``Heirs`` is a ``dict`` subclass persisted inside the
|
||||||
|
wallet DB under the ``"heirs"`` key.
|
||||||
|
|
||||||
|
The ``prepare_transactions`` / ``Heirs.buildTransactions`` functions turn the
|
||||||
|
heir list plus the wallet UTXOs into a set of time-locked inheritance
|
||||||
|
transactions (optionally including a will-executor fee output).
|
||||||
|
|
||||||
|
Will-executor "heirs" are synthetic entries whose key starts with the
|
||||||
|
``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
Dict,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
)
|
||||||
|
|
||||||
|
import dns
|
||||||
|
from dns.exception import DNSException
|
||||||
|
from electrum import (
|
||||||
|
bitcoin,
|
||||||
|
constants,
|
||||||
|
dnssec,
|
||||||
|
)
|
||||||
|
from electrum.logging import Logger, get_logger
|
||||||
|
from electrum.transaction import (
|
||||||
|
PartialTransaction,
|
||||||
|
PartialTxInput,
|
||||||
|
PartialTxOutput,
|
||||||
|
TxOutpoint,
|
||||||
|
)
|
||||||
|
from electrum.util import (
|
||||||
|
BitcoinException,
|
||||||
|
bfh,
|
||||||
|
read_json_file,
|
||||||
|
to_string,
|
||||||
|
trigger_callback,
|
||||||
|
write_json_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .util import Util
|
||||||
|
from .willexecutors import Willexecutors
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
|
||||||
|
|
||||||
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Column layout of a stored heir list. These indices are part of the on-disk
|
||||||
|
# wallet format and are relied upon all over the codebase, so they must NEVER
|
||||||
|
# be reordered.
|
||||||
|
HEIR_ADDRESS = 0 # destination Bitcoin address
|
||||||
|
HEIR_AMOUNT = 1 # requested amount (satoshis or "<n>%")
|
||||||
|
HEIR_LOCKTIME = 2 # locktime after which the heir may claim the funds
|
||||||
|
HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
|
||||||
|
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
||||||
|
TRANSACTION_LABEL = "inheritance transaction"
|
||||||
|
|
||||||
|
|
||||||
|
class AliasNotFoundException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reduce_outputs(in_amount, out_amount, fee, outputs):
|
||||||
|
if in_amount < out_amount:
|
||||||
|
for output in outputs:
|
||||||
|
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
||||||
|
|
||||||
|
|
||||||
|
def create_op_return_script(data_hex: str) -> bytes:
|
||||||
|
"""Build an OP_RETURN scriptPubKey (as raw bytes) from hex-encoded data.
|
||||||
|
|
||||||
|
Used to embed a small data payload (max 80 bytes) into a transaction
|
||||||
|
output. Raises ``ValueError`` when the payload exceeds the 80-byte limit.
|
||||||
|
"""
|
||||||
|
data = bytes.fromhex(data_hex)
|
||||||
|
|
||||||
|
if len(data) > 80:
|
||||||
|
raise ValueError("OP_RETURN data too big (max 80 bytes)")
|
||||||
|
|
||||||
|
# Manual construction: OP_RETURN opcode followed by the data push.
|
||||||
|
if len(data) <= 75:
|
||||||
|
# Most common form: OP_RETURN + 1-byte length prefix + data.
|
||||||
|
script = b'\x6a' + bytes([len(data)]) + data
|
||||||
|
else:
|
||||||
|
# For larger payloads (up to 80 bytes) use OP_PUSHDATA1.
|
||||||
|
script = b'\x6a\x4c' + bytes([len(data)]) + data
|
||||||
|
|
||||||
|
return script
|
||||||
|
|
||||||
|
def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
||||||
|
available_utxos = sorted(
|
||||||
|
available_utxos,
|
||||||
|
key=lambda x: "{}:{}:{}".format(
|
||||||
|
x.value_sats(), x.prevout.txid, x.prevout.out_idx
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# total_used_utxos = []
|
||||||
|
txsout = {}
|
||||||
|
locktimes_list = Util.get_lowest_locktimes(locktimes)
|
||||||
|
if not locktimes_list:
|
||||||
|
_logger.info("prepare transactions, no locktime")
|
||||||
|
return
|
||||||
|
locktime = locktimes_list[0]
|
||||||
|
|
||||||
|
heirs = locktimes[locktime]
|
||||||
|
true = True
|
||||||
|
while true:
|
||||||
|
true = False
|
||||||
|
fee = fees.get(locktime, 0)
|
||||||
|
out_amount = fee
|
||||||
|
description = ""
|
||||||
|
outputs = []
|
||||||
|
paid_heirs = {}
|
||||||
|
for name, heir in heirs.items():
|
||||||
|
if len(heir) > HEIR_REAL_AMOUNT and "DUST" not in str(
|
||||||
|
heir[HEIR_REAL_AMOUNT]
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||||
|
outputs.append(
|
||||||
|
PartialTxOutput.from_address_and_value(
|
||||||
|
heir[HEIR_ADDRESS], real_amount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out_amount += real_amount
|
||||||
|
description += f"{name}\n"
|
||||||
|
except BitcoinException as e:
|
||||||
|
_logger.info("exception decoding output {} - {}".format(type(e), e))
|
||||||
|
heir[HEIR_REAL_AMOUNT] = e
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
heir[HEIR_REAL_AMOUNT] = e
|
||||||
|
_logger.error(f"error preparing transactions: {e}")
|
||||||
|
pass
|
||||||
|
paid_heirs[name] = heir
|
||||||
|
|
||||||
|
in_amount = 0.0
|
||||||
|
used_utxos = []
|
||||||
|
try:
|
||||||
|
while utxo := available_utxos.pop():
|
||||||
|
value = utxo.value_sats()
|
||||||
|
in_amount += value
|
||||||
|
used_utxos.append(utxo)
|
||||||
|
if in_amount >= out_amount:
|
||||||
|
break
|
||||||
|
|
||||||
|
except IndexError as e:
|
||||||
|
_logger.error(
|
||||||
|
f"error preparing transactions index error {e} {in_amount}, {out_amount}"
|
||||||
|
)
|
||||||
|
pass
|
||||||
|
if int(in_amount) < int(out_amount):
|
||||||
|
_logger.error(
|
||||||
|
"error preparing transactions in_amount < out_amount ({} < {}) "
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
heirsvalue = out_amount
|
||||||
|
change = get_change_output(wallet, in_amount, out_amount, fee)
|
||||||
|
if change:
|
||||||
|
outputs.append(change)
|
||||||
|
for i in range(0, 100):
|
||||||
|
random.shuffle(outputs)
|
||||||
|
|
||||||
|
#op_return_text = "Hello Bal!"
|
||||||
|
|
||||||
|
## Convert text to hex
|
||||||
|
#op_return_hex = op_return_text.encode('utf-8').hex()
|
||||||
|
#op_return_script = create_op_return_script(op_return_hex)
|
||||||
|
#outputs.append(PartialTxOutput(value=0, scriptpubkey=op_return_script))
|
||||||
|
tx = PartialTransaction.from_io(
|
||||||
|
used_utxos,
|
||||||
|
outputs,
|
||||||
|
locktime=Util.parse_locktime_string(locktime),
|
||||||
|
version=2,
|
||||||
|
)
|
||||||
|
if len(description) > 0:
|
||||||
|
tx.description = description[:-1]
|
||||||
|
else:
|
||||||
|
tx.description = ""
|
||||||
|
tx.heirsvalue = heirsvalue
|
||||||
|
tx.set_rbf(True)
|
||||||
|
tx.remove_signatures()
|
||||||
|
txid = tx.txid()
|
||||||
|
if txid is None:
|
||||||
|
raise Exception(f"txid is none: {tx}")
|
||||||
|
|
||||||
|
tx.heirs = paid_heirs
|
||||||
|
tx.my_locktime = locktime
|
||||||
|
txsout[txid] = tx
|
||||||
|
|
||||||
|
if change:
|
||||||
|
change_idx = tx.get_output_idxs_from_address(change.address)
|
||||||
|
prevout = TxOutpoint(txid=bfh(tx.txid()), out_idx=change_idx.pop())
|
||||||
|
txin = PartialTxInput(prevout=prevout)
|
||||||
|
txin._trusted_value_sats = change.value
|
||||||
|
txin.script_descriptor = change.script_descriptor
|
||||||
|
txin.is_mine = True
|
||||||
|
txin._TxInput__address = change.address
|
||||||
|
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||||
|
txin._TxInput__value_sats = change.value
|
||||||
|
txin.utxo = tx
|
||||||
|
available_utxos.append(txin)
|
||||||
|
txsout[txid].available_utxos = available_utxos[:]
|
||||||
|
return txsout
|
||||||
|
|
||||||
|
|
||||||
|
def get_utxos_from_inputs(tx_inputs, tx, utxos):
|
||||||
|
for tx_input in tx_inputs:
|
||||||
|
prevoutstr = tx_input.prevout.to_str()
|
||||||
|
utxos[prevoutstr] = utxos.get(prevoutstr, {"input": tx_input, "txs": []})
|
||||||
|
utxos[prevoutstr]["txs"].append(tx)
|
||||||
|
return utxos
|
||||||
|
|
||||||
|
|
||||||
|
# TODO calculate de minimum inputs to be invalidated
|
||||||
|
def invalidate_inheritance_transactions(wallet):
|
||||||
|
# listids = []
|
||||||
|
utxos = {}
|
||||||
|
dtxs = {}
|
||||||
|
for k, v in wallet.get_all_labels().items():
|
||||||
|
tx = None
|
||||||
|
if TRANSACTION_LABEL == v:
|
||||||
|
tx = wallet.adb.get_transaction(k)
|
||||||
|
if tx:
|
||||||
|
dtxs[tx.txid()] = tx
|
||||||
|
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
||||||
|
|
||||||
|
for key, utxo in utxos.items():
|
||||||
|
txid = key.split(":")[0]
|
||||||
|
if txid in dtxs:
|
||||||
|
for tx in utxo["txs"]:
|
||||||
|
txid = tx.txid()
|
||||||
|
del dtxs[txid]
|
||||||
|
|
||||||
|
utxos = {}
|
||||||
|
for txid, tx in dtxs.items():
|
||||||
|
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
||||||
|
|
||||||
|
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
||||||
|
|
||||||
|
remaining = {}
|
||||||
|
invalidated = []
|
||||||
|
for key, value in utxos:
|
||||||
|
for tx in value["txs"]:
|
||||||
|
txid = tx.txid()
|
||||||
|
if txid not in invalidated:
|
||||||
|
invalidated.append(tx.txid())
|
||||||
|
remaining[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def print_transaction(heirs, tx, locktimes, tx_fees):
|
||||||
|
jtx = tx.to_json()
|
||||||
|
print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}")
|
||||||
|
print("---")
|
||||||
|
for inp in jtx["inputs"]:
|
||||||
|
print(f"{inp['address']}: {inp['value_sats']}")
|
||||||
|
print("---")
|
||||||
|
for out in jtx["outputs"]:
|
||||||
|
heirname = ""
|
||||||
|
for key in heirs.keys():
|
||||||
|
heir = heirs[key]
|
||||||
|
if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str(
|
||||||
|
jtx["locktime"]
|
||||||
|
):
|
||||||
|
heirname = key
|
||||||
|
print(f"{heirname}\t{out['address']}: {out['value_sats']}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
size = tx.estimated_size()
|
||||||
|
print(
|
||||||
|
"fee: {}\texpected: {}\tsize: {}".format(
|
||||||
|
tx.input_value() - tx.output_value(), size * tx_fees, size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
print(tx.serialize_to_network())
|
||||||
|
except Exception:
|
||||||
|
print("impossible to serialize")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def get_change_output(wallet, in_amount, out_amount, fee):
|
||||||
|
change_amount = int(in_amount - out_amount - fee)
|
||||||
|
if change_amount > wallet.dust_threshold():
|
||||||
|
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||||
|
out = PartialTxOutput.from_address_and_value(change_addresses[0], change_amount)
|
||||||
|
out.is_change = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value, _path="heirs", _depth=0):
|
||||||
|
"""Return a JSON-serializable deep copy of *value*.
|
||||||
|
|
||||||
|
The wallet DB persists the heirs dict via ``json_db.put``, which calls
|
||||||
|
``copy.deepcopy`` on the value. If any nested element is a live runtime
|
||||||
|
object (e.g. one holding a ``threading.RLock``), deepcopy raises
|
||||||
|
``TypeError: cannot pickle '_thread.RLock' object`` and the whole
|
||||||
|
"Build will" task fails.
|
||||||
|
|
||||||
|
To make persistence robust we coerce the structure to plain
|
||||||
|
JSON-compatible types (dict / list / str / int / float / bool / None).
|
||||||
|
Anything else is converted to ``str(value)`` and logged with its path so
|
||||||
|
the offending field can be identified, instead of crashing the task.
|
||||||
|
"""
|
||||||
|
# Primitive JSON scalars are kept as-is.
|
||||||
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1)
|
||||||
|
for k, v in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [
|
||||||
|
_json_safe(v, "{}[{}]".format(_path, i), _depth + 1)
|
||||||
|
for i, v in enumerate(value)
|
||||||
|
]
|
||||||
|
# Unexpected runtime object: do not let it reach deepcopy. Log where it
|
||||||
|
# was found so the real source can be fixed, then store a safe string.
|
||||||
|
_logger.error(
|
||||||
|
"heirs.save: non-serializable value at {} (type={}); coercing to str. "
|
||||||
|
"value={!r}".format(_path, type(value).__name__, value)
|
||||||
|
)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class Heirs(dict, Logger):
|
||||||
|
|
||||||
|
def __init__(self, wallet):
|
||||||
|
Logger.__init__(self)
|
||||||
|
self.db = wallet.db
|
||||||
|
self.wallet = wallet
|
||||||
|
d = self.db.get("heirs", {})
|
||||||
|
try:
|
||||||
|
self.update(d)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
def invalidate_transactions(self, wallet):
|
||||||
|
invalidate_inheritance_transactions(wallet)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
# Sanitise the heirs mapping before handing it to the wallet DB: this
|
||||||
|
# guarantees only JSON-serializable values are stored and prevents the
|
||||||
|
# "cannot pickle '_thread.RLock' object" failure that aborted the
|
||||||
|
# Build-will task when a runtime object slipped into an heir value.
|
||||||
|
self.db.put("heirs", _json_safe(dict(self)))
|
||||||
|
|
||||||
|
def import_file(self, path):
|
||||||
|
data = read_json_file(path)
|
||||||
|
data = Heirs._validate(data)
|
||||||
|
self.update(data)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def export_file(self, path):
|
||||||
|
write_json_file(path, self)
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
dict.__setitem__(self, key, value)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def pop(self, key):
|
||||||
|
if key in self.keys():
|
||||||
|
res = dict.pop(self, key)
|
||||||
|
self.save()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_locktimes(self, from_locktime, a=False):
|
||||||
|
locktimes = {}
|
||||||
|
for key in self.keys():
|
||||||
|
locktime = Util.parse_locktime_string(self[key][HEIR_LOCKTIME])
|
||||||
|
if locktime > from_locktime and not a or locktime <= from_locktime and a:
|
||||||
|
locktimes[int(locktime)] = None
|
||||||
|
return list(locktimes.keys())
|
||||||
|
|
||||||
|
def check_locktime(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def normalize_perc(
|
||||||
|
self, heir_list, total_balance, relative_balance, wallet, real=False
|
||||||
|
):
|
||||||
|
amount = 0
|
||||||
|
for key, v in heir_list.items():
|
||||||
|
try:
|
||||||
|
column = HEIR_AMOUNT
|
||||||
|
if real:
|
||||||
|
column = HEIR_REAL_AMOUNT
|
||||||
|
if "DUST" in str(v[column]):
|
||||||
|
column = HEIR_DUST_AMOUNT
|
||||||
|
value = int(
|
||||||
|
math.floor(
|
||||||
|
total_balance
|
||||||
|
/ relative_balance
|
||||||
|
* self.amount_to_float(v[column])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if value > wallet.dust_threshold():
|
||||||
|
heir_list[key].insert(HEIR_REAL_AMOUNT, value)
|
||||||
|
amount += value
|
||||||
|
else:
|
||||||
|
heir_list[key].insert(HEIR_REAL_AMOUNT, f"DUST: {value}")
|
||||||
|
heir_list[key].insert(HEIR_DUST_AMOUNT, value)
|
||||||
|
_logger.info(f"{key}, {value} is dust will be ignored")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
return amount
|
||||||
|
|
||||||
|
def amount_to_float(self, amount):
|
||||||
|
try:
|
||||||
|
return float(amount)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return float(amount[:-1])
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def fixed_percent_lists_amount(self, from_locktime, dust_threshold, reverse=False):
|
||||||
|
fixed_heirs = {}
|
||||||
|
fixed_amount = 0.0
|
||||||
|
percent_heirs = {}
|
||||||
|
percent_amount = 0.0
|
||||||
|
fixed_amount_with_dust = 0.0
|
||||||
|
for key in self.keys():
|
||||||
|
try:
|
||||||
|
cmp = (
|
||||||
|
Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) - from_locktime
|
||||||
|
)
|
||||||
|
if cmp <= 0:
|
||||||
|
_logger.debug(
|
||||||
|
"cmp < 0 {} {} {} {}".format(
|
||||||
|
cmp, key, self[key][HEIR_LOCKTIME], from_locktime
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if Util.is_perc(self[key][HEIR_AMOUNT]):
|
||||||
|
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
||||||
|
percent_heirs[key] = list(self[key])
|
||||||
|
else:
|
||||||
|
heir_amount = int(math.floor(float(self[key][HEIR_AMOUNT])))
|
||||||
|
fixed_amount_with_dust += heir_amount
|
||||||
|
fixed_heirs[key] = list(self[key])
|
||||||
|
if heir_amount > dust_threshold:
|
||||||
|
fixed_amount += heir_amount
|
||||||
|
fixed_heirs[key].insert(HEIR_REAL_AMOUNT, heir_amount)
|
||||||
|
else:
|
||||||
|
fixed_heirs[key] = list(self[key])
|
||||||
|
fixed_heirs[key].insert(
|
||||||
|
HEIR_REAL_AMOUNT, f"DUST: {heir_amount}"
|
||||||
|
)
|
||||||
|
fixed_heirs[key].insert(HEIR_DUST_AMOUNT, heir_amount)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(e)
|
||||||
|
return (
|
||||||
|
fixed_heirs,
|
||||||
|
fixed_amount,
|
||||||
|
percent_heirs,
|
||||||
|
percent_amount,
|
||||||
|
fixed_amount_with_dust,
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare_lists(
|
||||||
|
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0
|
||||||
|
):
|
||||||
|
if balance<total_fees or balance < wallet.dust_threshold():
|
||||||
|
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
|
||||||
|
willexecutors_amount = 0
|
||||||
|
willexecutors = {}
|
||||||
|
heir_list = {}
|
||||||
|
onlyfixed = False
|
||||||
|
newbalance = balance - total_fees
|
||||||
|
locktimes = self.get_locktimes(from_locktime)
|
||||||
|
if willexecutor:
|
||||||
|
for locktime in locktimes:
|
||||||
|
if int(Util.int_locktime(locktime)) > int(from_locktime):
|
||||||
|
try:
|
||||||
|
base_fee = int(willexecutor["base_fee"])
|
||||||
|
willexecutors_amount += base_fee
|
||||||
|
h = [None] * 4
|
||||||
|
h[HEIR_AMOUNT] = base_fee
|
||||||
|
h[HEIR_REAL_AMOUNT] = base_fee
|
||||||
|
h[HEIR_LOCKTIME] = locktime
|
||||||
|
h[HEIR_ADDRESS] = willexecutor["address"]
|
||||||
|
willexecutors[
|
||||||
|
'w!ll3x3c"' + willexecutor["url"] + '"' + str(locktime)
|
||||||
|
] = h
|
||||||
|
except Exception:
|
||||||
|
return [], False
|
||||||
|
else:
|
||||||
|
_logger.error(
|
||||||
|
f"heir excluded from will locktime({locktime}){Util.int_locktime(locktime)}<minimum{from_locktime}"
|
||||||
|
),
|
||||||
|
heir_list.update(willexecutors)
|
||||||
|
newbalance -= willexecutors_amount
|
||||||
|
if newbalance < 0:
|
||||||
|
raise WillExecutorFeeException(willexecutor)
|
||||||
|
(
|
||||||
|
fixed_heirs,
|
||||||
|
fixed_amount,
|
||||||
|
percent_heirs,
|
||||||
|
percent_amount,
|
||||||
|
fixed_amount_with_dust,
|
||||||
|
) = self.fixed_percent_lists_amount(from_locktime, wallet.dust_threshold())
|
||||||
|
if fixed_amount > newbalance:
|
||||||
|
fixed_amount = self.normalize_perc(
|
||||||
|
fixed_heirs, newbalance, fixed_amount, wallet
|
||||||
|
)
|
||||||
|
onlyfixed = True
|
||||||
|
|
||||||
|
heir_list.update(fixed_heirs)
|
||||||
|
|
||||||
|
newbalance -= fixed_amount
|
||||||
|
if newbalance > 0:
|
||||||
|
perc_amount = self.normalize_perc(
|
||||||
|
percent_heirs, newbalance, percent_amount, wallet
|
||||||
|
)
|
||||||
|
newbalance -= perc_amount
|
||||||
|
heir_list.update(percent_heirs)
|
||||||
|
if newbalance > 0:
|
||||||
|
newbalance += fixed_amount
|
||||||
|
fixed_amount = self.normalize_perc(
|
||||||
|
fixed_heirs, newbalance, fixed_amount_with_dust, wallet, real=True
|
||||||
|
)
|
||||||
|
newbalance -= fixed_amount
|
||||||
|
heir_list.update(fixed_heirs)
|
||||||
|
|
||||||
|
heir_list = sorted(
|
||||||
|
heir_list.items(),
|
||||||
|
key=lambda item: Util.parse_locktime_string(item[1][HEIR_LOCKTIME]),
|
||||||
|
)
|
||||||
|
|
||||||
|
locktimes = {}
|
||||||
|
for key, value in heir_list:
|
||||||
|
locktime = Util.parse_locktime_string(value[HEIR_LOCKTIME])
|
||||||
|
if locktime not in locktimes:
|
||||||
|
locktimes[locktime] = {key: value}
|
||||||
|
else:
|
||||||
|
locktimes[locktime][key] = value
|
||||||
|
return locktimes, onlyfixed
|
||||||
|
|
||||||
|
def is_perc(self, key):
|
||||||
|
return Util.is_perc(self[key][HEIR_AMOUNT])
|
||||||
|
|
||||||
|
def buildTransactions(
|
||||||
|
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
||||||
|
):
|
||||||
|
Heirs._validate(self)
|
||||||
|
if len(self) <= 0:
|
||||||
|
_logger.info("while building transactions there was no heirs")
|
||||||
|
return
|
||||||
|
balance = 0.0
|
||||||
|
len_utxo_set = 0
|
||||||
|
available_utxos = []
|
||||||
|
if not utxos:
|
||||||
|
utxos = wallet.get_utxos()
|
||||||
|
willexecutors = Willexecutors.get_willexecutors(bal_plugin) or {}
|
||||||
|
self.decimal_point = bal_plugin.get_decimal_point()
|
||||||
|
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
|
||||||
|
for utxo in utxos:
|
||||||
|
if utxo.value_sats() > 0 * tx_fees:
|
||||||
|
balance += utxo.value_sats()
|
||||||
|
len_utxo_set += 1
|
||||||
|
available_utxos.append(utxo)
|
||||||
|
if len_utxo_set == 0:
|
||||||
|
_logger.info("no usable utxos")
|
||||||
|
return
|
||||||
|
j = -2
|
||||||
|
willexecutorsitems = list(willexecutors.items())
|
||||||
|
willexecutorslen = len(willexecutorsitems)
|
||||||
|
alltxs = {}
|
||||||
|
while True:
|
||||||
|
j += 1
|
||||||
|
if j >= willexecutorslen:
|
||||||
|
break
|
||||||
|
elif 0 <= j:
|
||||||
|
url, willexecutor = willexecutorsitems[j]
|
||||||
|
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold():
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
willexecutor["url"] = url
|
||||||
|
elif j == -1:
|
||||||
|
if not no_willexecutors:
|
||||||
|
continue
|
||||||
|
url = willexecutor = False
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
fees = {}
|
||||||
|
i = 0
|
||||||
|
while i < 10:
|
||||||
|
txs = {}
|
||||||
|
redo = False
|
||||||
|
i += 1
|
||||||
|
total_fees = 0
|
||||||
|
for fee in fees:
|
||||||
|
total_fees += int(fees[fee])
|
||||||
|
# newbalance = balance
|
||||||
|
try:
|
||||||
|
locktimes, onlyfixed = self.prepare_lists(
|
||||||
|
balance, total_fees, wallet, willexecutor, from_locktime
|
||||||
|
)
|
||||||
|
except WillExecutorFeeException:
|
||||||
|
i = 10
|
||||||
|
continue
|
||||||
|
if locktimes:
|
||||||
|
try:
|
||||||
|
txs = prepare_transactions(
|
||||||
|
locktimes, available_utxos[:], fees, wallet
|
||||||
|
)
|
||||||
|
if not txs:
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
f"build transactions: error preparing transactions: {e}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if "w!ll3x3c" in e.heirname:
|
||||||
|
Willexecutors.is_selected(
|
||||||
|
e.heirname[len("w!ll3x3c") :], False
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
raise e
|
||||||
|
total_fees = 0
|
||||||
|
total_fees_real = 0
|
||||||
|
total_in = 0
|
||||||
|
for txid, tx in txs.items():
|
||||||
|
tx.willexecutor = willexecutor
|
||||||
|
fee = tx.estimated_size() * tx_fees
|
||||||
|
txs[txid].tx_fees = tx_fees
|
||||||
|
total_fees += fee
|
||||||
|
total_fees_real += tx.get_fee()
|
||||||
|
total_in += tx.input_value()
|
||||||
|
rfee = tx.input_value() - tx.output_value()
|
||||||
|
if rfee < fee or rfee > fee + wallet.dust_threshold():
|
||||||
|
redo = True
|
||||||
|
# oldfees = fees.get(tx.my_locktime, 0)
|
||||||
|
fees[tx.my_locktime] = fee
|
||||||
|
|
||||||
|
if balance - total_in > wallet.dust_threshold():
|
||||||
|
redo = True
|
||||||
|
if not redo:
|
||||||
|
break
|
||||||
|
if i >= 10:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
_logger.info(
|
||||||
|
f"no locktimes for willexecutor {willexecutor} skipped"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
alltxs.update(txs)
|
||||||
|
|
||||||
|
return alltxs
|
||||||
|
|
||||||
|
def get_transactions(
|
||||||
|
self, bal_plugin, wallet, tx_fees, utxos=None, from_locktime=0
|
||||||
|
):
|
||||||
|
txs = self.buildTransactions(bal_plugin, wallet, tx_fees, utxos, from_locktime)
|
||||||
|
if txs:
|
||||||
|
temp_txs = {}
|
||||||
|
for txid in txs:
|
||||||
|
if txs[txid].available_utxos:
|
||||||
|
temp_txs.update(
|
||||||
|
self.get_transactions(
|
||||||
|
bal_plugin,
|
||||||
|
wallet,
|
||||||
|
tx_fees,
|
||||||
|
txs[txid].available_utxos,
|
||||||
|
txs[txid].locktime,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
txs.update(temp_txs)
|
||||||
|
return txs
|
||||||
|
|
||||||
|
def resolve(self, k):
|
||||||
|
if bitcoin.is_address(k):
|
||||||
|
return {"address": k, "type": "address"}
|
||||||
|
if k in self.keys():
|
||||||
|
_type, addr = self[k]
|
||||||
|
if _type == "address":
|
||||||
|
return {"address": addr, "type": "heir"}
|
||||||
|
if openalias := self.resolve_openalias(k):
|
||||||
|
return openalias
|
||||||
|
raise AliasNotFoundException("Invalid Bitcoin address or alias", k)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def resolve_openalias(cls, url: str) -> Dict[str, Any]:
|
||||||
|
out = cls._resolve_openalias(url)
|
||||||
|
if out:
|
||||||
|
address, name, validated = out
|
||||||
|
return {
|
||||||
|
"address": address,
|
||||||
|
"name": name,
|
||||||
|
"type": "openalias",
|
||||||
|
"validated": validated,
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def by_name(self, name):
|
||||||
|
for k in self.keys():
|
||||||
|
_type, addr = self[k]
|
||||||
|
if addr.casefold() == name.casefold():
|
||||||
|
return {"name": addr, "type": _type, "address": k}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_openalias(self, config: "SimpleConfig"):
|
||||||
|
self.alias_info = None
|
||||||
|
alias = config.OPENALIAS_ID
|
||||||
|
if alias:
|
||||||
|
alias = str(alias)
|
||||||
|
|
||||||
|
def f():
|
||||||
|
self.alias_info = self._resolve_openalias(alias)
|
||||||
|
trigger_callback("alias_received")
|
||||||
|
|
||||||
|
t = threading.Thread(target=f)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_openalias(cls, url: str) -> Optional[Tuple[str, str, bool]]:
|
||||||
|
# support email-style addresses, per the OA standard
|
||||||
|
url = url.replace("@", ".")
|
||||||
|
try:
|
||||||
|
records, validated = dnssec.query(url, dns.rdatatype.TXT)
|
||||||
|
except DNSException as e:
|
||||||
|
_logger.info(f"Error resolving openalias: {repr(e)}")
|
||||||
|
return None
|
||||||
|
prefix = "btc"
|
||||||
|
for record in records:
|
||||||
|
string = to_string(record.strings[0], "utf8")
|
||||||
|
if string.startswith("oa1:" + prefix):
|
||||||
|
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
|
||||||
|
name = cls.find_regex(string, r"recipient_name=([^;]+)")
|
||||||
|
if not name:
|
||||||
|
name = address
|
||||||
|
if not address:
|
||||||
|
continue
|
||||||
|
return address, name, validated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_regex(haystack, needle):
|
||||||
|
regex = re.compile(needle)
|
||||||
|
try:
|
||||||
|
return regex.search(haystack).groups()[0]
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_address(address):
|
||||||
|
if not bitcoin.is_address(address, net=constants.net):
|
||||||
|
raise NotAnAddress(f"not an address,{address}")
|
||||||
|
return address
|
||||||
|
|
||||||
|
def validate_amount(amount):
|
||||||
|
try:
|
||||||
|
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
||||||
|
if famount <= 0.00000001:
|
||||||
|
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
||||||
|
except Exception as e:
|
||||||
|
raise AmountNotValid(f"amount not properly formatted, {e}")
|
||||||
|
return amount
|
||||||
|
|
||||||
|
def validate_locktime(locktime, timestamp_to_check=False):
|
||||||
|
try:
|
||||||
|
if timestamp_to_check:
|
||||||
|
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
||||||
|
raise HeirExpiredException()
|
||||||
|
except Exception as e:
|
||||||
|
raise LocktimeNotValid(f"locktime string not properly formatted, {e}")
|
||||||
|
return locktime
|
||||||
|
|
||||||
|
def validate_heir(k, v, timestamp_to_check=False):
|
||||||
|
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
||||||
|
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
||||||
|
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
||||||
|
return (address, amount, locktime)
|
||||||
|
|
||||||
|
def _validate(data, timestamp_to_check=False):
|
||||||
|
|
||||||
|
for k, v in list(data.items()):
|
||||||
|
if k == "heirs":
|
||||||
|
return Heirs._validate(v, timestamp_to_check)
|
||||||
|
try:
|
||||||
|
Heirs.validate_heir(k, v, timestamp_to_check)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.info(f"exception heir removed {e}")
|
||||||
|
data.pop(k)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class NotAnAddress(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AmountNotValid(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LocktimeNotValid(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HeirExpiredException(LocktimeNotValid):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HeirAmountIsDustException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NoHeirsException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WillExecutorFeeException(Exception):
|
||||||
|
def __init__(self, willexecutor):
|
||||||
|
self.willexecutor = willexecutor
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "WillExecutorFeeException: {} fee:{}".format(
|
||||||
|
self.willexecutor["url"], self.willexecutor["base_fee"]
|
||||||
|
)
|
||||||
|
class BalanceTooLowException(Exception):
|
||||||
|
def __init__(self,balance, dust_threshold, fees):
|
||||||
|
self.balance=balance
|
||||||
|
self.dust_threshold = dust_threshold
|
||||||
|
self.fees = fees
|
||||||
|
def __str__(self):
|
||||||
|
return f"Balance too low, balance: {self.balance}, dust threshold: {self.dust_threshold}, fees: {self.fees}"
|
||||||
400
bal/core/plugin_base.py
Normal file
400
bal/core/plugin_base.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
bal.core.plugin_base
|
||||||
|
=====================
|
||||||
|
|
||||||
|
GUI-agnostic foundation of the plugin.
|
||||||
|
|
||||||
|
It contains:
|
||||||
|
* :class:`BalConfig` - a thin typed wrapper around an Electrum config key
|
||||||
|
with a default value.
|
||||||
|
* :class:`BalPlugin` - the base plugin class (extends Electrum's
|
||||||
|
``BasePlugin``) holding every configuration option
|
||||||
|
and the default "will settings". The Qt-specific
|
||||||
|
``Plugin`` subclass lives in ``bal.gui.qt.plugin``.
|
||||||
|
* :class:`BalTimestamp`- helper to convert between relative durations
|
||||||
|
(``"30d"``, ``"1y"``) and absolute timestamps.
|
||||||
|
|
||||||
|
It also registers the three custom persisted dictionaries (``heirs``,
|
||||||
|
``will`` and ``will_settings``) with Electrum's JSON database so they are
|
||||||
|
serialised together with the wallet file.
|
||||||
|
|
||||||
|
This module performs **no** GUI work and imports nothing from PyQt / electrum.gui.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
from electrum import constants, json_db
|
||||||
|
from electrum.logging import get_logger
|
||||||
|
from electrum.plugin import BasePlugin
|
||||||
|
from electrum.transaction import tx_from_any
|
||||||
|
|
||||||
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Wallet-DB registration
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Electrum needs to know how to (de)serialise the custom dictionaries the
|
||||||
|
# plugin stores inside the wallet file. ``register_dict`` associates a key
|
||||||
|
# name with a conversion callable applied to each value when the wallet is
|
||||||
|
# loaded. ``will`` values run through ``get_will`` so the stored transaction
|
||||||
|
# hex is turned back into a ``Transaction`` object.
|
||||||
|
def get_will(x):
|
||||||
|
"""Deserialise a stored will entry, rebuilding its ``tx`` object."""
|
||||||
|
try:
|
||||||
|
x["tx"] = tx_from_any(x["tx"])
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
json_db.register_dict("heirs", tuple, None)
|
||||||
|
json_db.register_dict("will", dict, None)
|
||||||
|
json_db.register_dict("will_settings", lambda x: x, None)
|
||||||
|
|
||||||
|
|
||||||
|
class BalConfig:
|
||||||
|
"""Typed accessor for a single Electrum configuration key.
|
||||||
|
|
||||||
|
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
|
||||||
|
when the key is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config, name, default):
|
||||||
|
self.config = config
|
||||||
|
self.name = name
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def get(self, default=None):
|
||||||
|
"""Return the stored value, falling back to ``default`` then ``self.default``."""
|
||||||
|
v = self.config.get(self.name, default)
|
||||||
|
if v is None:
|
||||||
|
if default is not None:
|
||||||
|
v = default
|
||||||
|
else:
|
||||||
|
v = self.default
|
||||||
|
return v
|
||||||
|
|
||||||
|
def set(self, value, save=True):
|
||||||
|
"""Persist ``value`` for this key."""
|
||||||
|
self.config.set_key(self.name, value, save=save)
|
||||||
|
|
||||||
|
|
||||||
|
class BalPlugin(BasePlugin):
|
||||||
|
"""Base plugin: holds configuration and default inheritance settings.
|
||||||
|
|
||||||
|
The GUI layer subclasses this in ``bal.gui.qt.plugin.Plugin`` and adds the
|
||||||
|
Electrum ``@hook`` methods. Keeping the configuration here means the CLI
|
||||||
|
layer (or unit tests) can use the plugin logic without importing Qt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_version = None
|
||||||
|
__version__ = "0.3.3" # AUTOMATICALLY GENERATED DO NOT EDIT
|
||||||
|
|
||||||
|
# Command used to open an .ics calendar file, per operating system.
|
||||||
|
default_app = {
|
||||||
|
"Linux": "xdg-open",
|
||||||
|
"Windows": "cmd /c start",
|
||||||
|
"Darwin": "open",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
||||||
|
chainname = (
|
||||||
|
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default geometry hint for some dialogs (kept from the original code).
|
||||||
|
SIZE = (159, 97)
|
||||||
|
|
||||||
|
def version(self):
|
||||||
|
"""Return the plugin version, read once from the ``VERSION`` file."""
|
||||||
|
if not self._version:
|
||||||
|
try:
|
||||||
|
f = ""
|
||||||
|
with open("{}/VERSION".format(self.plugin_dir), "r") as fi:
|
||||||
|
f = str(fi.read())
|
||||||
|
self._version = f.strip()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"failed to get version: {e}")
|
||||||
|
self._version = "unknown"
|
||||||
|
return self._version
|
||||||
|
|
||||||
|
def __init__(self, parent, config, name):
|
||||||
|
self.logger = get_logger(__name__)
|
||||||
|
BasePlugin.__init__(self, parent, config, name)
|
||||||
|
|
||||||
|
# Base directory for plugin data inside the Electrum data dir.
|
||||||
|
self.base_dir = os.path.join(config.electrum_path(), "bal")
|
||||||
|
self.plugin_dir = os.path.split(os.path.realpath(__file__))[0]
|
||||||
|
|
||||||
|
# Make the plugin importable when loaded from a zip (legacy behaviour:
|
||||||
|
# the parent directory of this file is added to ``sys.path``).
|
||||||
|
zipfile = "/".join(self.plugin_dir.split("/")[:-1])
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, zipfile)
|
||||||
|
|
||||||
|
self.parent = parent
|
||||||
|
self.config = config
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
# Configuration options (all persisted via Electrum's config).
|
||||||
|
# ---------------------------------------------------------------- #
|
||||||
|
self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True)
|
||||||
|
self.BROADCAST = BalConfig(config, "bal_broadcast", True)
|
||||||
|
self.LOCKTIME_TIME = BalConfig(config, "bal_locktime_time", 90)
|
||||||
|
self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7)
|
||||||
|
self.ENABLE_MULTIVERSE = BalConfig(config, "bal_enable_multiverse", False)
|
||||||
|
self.TX_FEES = BalConfig(config, "bal_tx_fees", 100)
|
||||||
|
self.INVALIDATE = BalConfig(config, "bal_invalidate", True)
|
||||||
|
self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True)
|
||||||
|
self.PREVIEW = BalConfig(config, "bal_preview", True)
|
||||||
|
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
||||||
|
|
||||||
|
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
|
||||||
|
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
||||||
|
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
||||||
|
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
|
||||||
|
self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True)
|
||||||
|
self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True)
|
||||||
|
self.ALARM_NUMBER = BalConfig(config, "bal_alarm_number", 3)
|
||||||
|
self.WELIST_SERVER = BalConfig(
|
||||||
|
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
|
||||||
|
)
|
||||||
|
self.EVENT_DESCRIPTION = BalConfig(
|
||||||
|
config,
|
||||||
|
"bal_event_description",
|
||||||
|
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
||||||
|
)
|
||||||
|
self.EVENT_SUMMARY = BalConfig(
|
||||||
|
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default will-executor servers, keyed by network.
|
||||||
|
self.WILLEXECUTORS = BalConfig(
|
||||||
|
config,
|
||||||
|
"bal_willexecutors",
|
||||||
|
{
|
||||||
|
"mainnet": {
|
||||||
|
"https://we.bitcoin-after.life": {
|
||||||
|
"base_fee": 100000,
|
||||||
|
"status": "New",
|
||||||
|
"info": "Bitcoin After Life Will Executor",
|
||||||
|
"address": "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf",
|
||||||
|
"selected": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testnet": {
|
||||||
|
"https://we.bitcoin-after.life": {
|
||||||
|
"base_fee": 100000,
|
||||||
|
"status": "New",
|
||||||
|
"info": "Bitcoin After Life Will Executor",
|
||||||
|
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||||
|
"selected": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testnet4": {
|
||||||
|
"https://we.bitcoin-after.life": {
|
||||||
|
"base_fee": 100000,
|
||||||
|
"status": "New",
|
||||||
|
"info": "Bitcoin After Life Will Executor",
|
||||||
|
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||||
|
"selected": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regtest": {
|
||||||
|
"https://we.bitcoin-after.life": {
|
||||||
|
"base_fee": 100000,
|
||||||
|
"status": "New",
|
||||||
|
"info": "Bitcoin After Life Will Executor",
|
||||||
|
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||||
|
"selected": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.WILL_SETTINGS = BalConfig(
|
||||||
|
config,
|
||||||
|
"bal_will_settings",
|
||||||
|
BalPlugin.default_will_settings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.system = platform.system()
|
||||||
|
self.CALENDAR_APP = BalConfig(
|
||||||
|
config, "bal_open_app", self.default_app.get(self.system, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cached toggles used by the GUI list filters.
|
||||||
|
self._hide_invalidated = self.HIDE_INVALIDATED.get()
|
||||||
|
self._hide_replaced = self.HIDE_REPLACED.get()
|
||||||
|
|
||||||
|
def resource_path(self, *parts):
|
||||||
|
"""Absolute path to a file bundled inside the plugin directory."""
|
||||||
|
return os.path.join(self.plugin_dir, *parts)
|
||||||
|
|
||||||
|
def sync_hide_filters(self):
|
||||||
|
"""Re-read the "hide" filter flags from the persisted config.
|
||||||
|
|
||||||
|
The cached ``_hide_invalidated`` / ``_hide_replaced`` flags are used by
|
||||||
|
the GUI list to decide which rows to skip. They can be changed from two
|
||||||
|
different places:
|
||||||
|
|
||||||
|
* the list toolbar buttons, which call :meth:`hide_invalidated` /
|
||||||
|
:meth:`hide_replaced` (a toggle that updates both the cache and the
|
||||||
|
config), and
|
||||||
|
* the Settings dialog checkboxes, which write the config directly
|
||||||
|
(``BalConfig.set``) without touching the cached flags.
|
||||||
|
|
||||||
|
In the second case the cache and the config would drift apart and the
|
||||||
|
transaction list would keep filtering with the *old* value, so the
|
||||||
|
toggled rows never appear/disappear until Electrum is restarted.
|
||||||
|
Re-syncing the cache from the config here (called by ``update_all``)
|
||||||
|
keeps every code path coherent regardless of where the change came
|
||||||
|
from.
|
||||||
|
"""
|
||||||
|
self._hide_invalidated = self.HIDE_INVALIDATED.get()
|
||||||
|
self._hide_replaced = self.HIDE_REPLACED.get()
|
||||||
|
|
||||||
|
def hide_invalidated(self):
|
||||||
|
"""Toggle (and persist) the "hide invalidated transactions" filter."""
|
||||||
|
self._hide_invalidated = not self._hide_invalidated
|
||||||
|
self.HIDE_INVALIDATED.set(self._hide_invalidated)
|
||||||
|
|
||||||
|
def hide_replaced(self):
|
||||||
|
"""Toggle (and persist) the "hide replaced transactions" filter."""
|
||||||
|
self._hide_replaced = not self._hide_replaced
|
||||||
|
self.HIDE_REPLACED.set(self._hide_replaced)
|
||||||
|
|
||||||
|
def validate_will_settings(self, will_settings):
|
||||||
|
"""Fill in any missing will-setting with its default value."""
|
||||||
|
defaults = BalPlugin.default_will_settings()
|
||||||
|
if not will_settings:
|
||||||
|
will_settings = []
|
||||||
|
if int(will_settings.get("baltx_fees", 0)) < 1:
|
||||||
|
will_settings["baltx_fees"] = defaults['baltx_fees']
|
||||||
|
if not will_settings.get("threshold"):
|
||||||
|
will_settings["threshold"] = defaults['threshold']
|
||||||
|
if not will_settings.get("locktime"):
|
||||||
|
will_settings["locktime"] = defaults['locktime']
|
||||||
|
return will_settings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def default_will_settings():
|
||||||
|
"""Default will settings: a fee rate plus absolute threshold/locktime."""
|
||||||
|
will_settings = {"baltx_fees": 100}
|
||||||
|
will_settings.update(BalPlugin.default_will_settings_absolute())
|
||||||
|
return will_settings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def default_will_settings_absolute():
|
||||||
|
"""Convert the default relative dates into absolute timestamps (from today)."""
|
||||||
|
relative_dates = BalPlugin.default_will_settings_relative()
|
||||||
|
today = date.today()
|
||||||
|
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
|
||||||
|
threshold = (
|
||||||
|
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
||||||
|
).timestamp()
|
||||||
|
locktime = (
|
||||||
|
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days())
|
||||||
|
).timestamp()
|
||||||
|
return {"threshold": threshold, "locktime": locktime}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def default_will_settings_relative():
|
||||||
|
"""Default relative dates: 30 days threshold, 1 year locktime."""
|
||||||
|
return {"threshold": "30d", "locktime": "1y"}
|
||||||
|
|
||||||
|
|
||||||
|
class BalTimestamp:
|
||||||
|
"""Parse and convert relative durations / absolute timestamps.
|
||||||
|
|
||||||
|
A value may be:
|
||||||
|
* ``"<n>y"`` -> ``n`` years (unit ``"y"``)
|
||||||
|
* ``"<n>d"`` -> ``n`` days (unit ``"d"``)
|
||||||
|
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
||||||
|
"""
|
||||||
|
|
||||||
|
value = None
|
||||||
|
unit = None
|
||||||
|
|
||||||
|
def __init__(self, value):
|
||||||
|
str_value = str(value)
|
||||||
|
if str_value and str_value[-1].lower() in ("y", "d"):
|
||||||
|
self.value = int(str_value[:-1])
|
||||||
|
self.unit = str_value[-1]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.value = int(value)
|
||||||
|
except Exception as _e:
|
||||||
|
self.value = 1
|
||||||
|
self.unit = None
|
||||||
|
|
||||||
|
def duration_to_days(self):
|
||||||
|
"""Return the duration expressed in days (years are ``*365``)."""
|
||||||
|
return self.value * 365 if self.unit == 'y' else self.value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_fromtimestamp(ts):
|
||||||
|
"""``datetime.fromtimestamp`` that never raises ``OverflowError``.
|
||||||
|
|
||||||
|
On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp`` raises
|
||||||
|
``OverflowError: Python int too large to convert to C int`` for any
|
||||||
|
timestamp past the year-2038 limit (e.g. ``NLOCKTIME_MAX = 2**32 - 1``,
|
||||||
|
used as the default/sentinel locktime). On 64-bit Linux the same call
|
||||||
|
succeeds, which is why this only crashed on the user's Windows build.
|
||||||
|
|
||||||
|
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
|
||||||
|
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
|
||||||
|
"""
|
||||||
|
INT32_MAX = 2 ** 31 - 1
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(ts)
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(min(int(ts), INT32_MAX))
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
return datetime.fromtimestamp(INT32_MAX)
|
||||||
|
|
||||||
|
def to_date(self, from_date=None, reverse=False):
|
||||||
|
"""Resolve to a ``datetime``.
|
||||||
|
|
||||||
|
For absolute values the stored timestamp is returned; for relative ones
|
||||||
|
the duration is added to (or, if ``reverse``, subtracted from)
|
||||||
|
``from_date`` (defaulting to *now*), normalised to midnight.
|
||||||
|
"""
|
||||||
|
if self.unit is None:
|
||||||
|
return self._safe_fromtimestamp(self.value)
|
||||||
|
else:
|
||||||
|
if from_date is None:
|
||||||
|
from_date = datetime.now()
|
||||||
|
if isinstance(from_date, (int, float)):
|
||||||
|
from_date = self._safe_fromtimestamp(from_date)
|
||||||
|
reverse = 1 if not reverse else -1
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
from_date + (reverse * timedelta(days=self.duration_to_days()))
|
||||||
|
).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
except (OverflowError, OSError, ValueError):
|
||||||
|
# Duration overflowed datetime's range; clamp to INT32_MAX.
|
||||||
|
return self._safe_fromtimestamp(2 ** 31 - 1).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_timestamp(self, from_date=None, reverse=False):
|
||||||
|
"""Same as :meth:`to_date` but returns a UNIX timestamp."""
|
||||||
|
return self.to_date(from_date, reverse).timestamp()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.unit is None:
|
||||||
|
return self._safe_fromtimestamp(self.value).isoformat()
|
||||||
|
else:
|
||||||
|
return f"{self.value}{self.unit}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
if self.unit is None:
|
||||||
|
return self._safe_fromtimestamp(self.value).isoformat()
|
||||||
|
else:
|
||||||
|
return f"{self.value}{self.unit}"
|
||||||
551
bal/core/util.py
Normal file
551
bal/core/util.py
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
"""
|
||||||
|
bal.core.util
|
||||||
|
=============
|
||||||
|
|
||||||
|
Small, stateless helper functions shared across the whole plugin.
|
||||||
|
|
||||||
|
This module is intentionally GUI-free: it only deals with locktimes, amount
|
||||||
|
encoding/decoding, and comparing transactions / inputs / outputs / heirs.
|
||||||
|
|
||||||
|
Only UNIX timestamps are used for locktimes; block-height locktimes have been
|
||||||
|
removed. A locktime is always an absolute UNIX timestamp (seconds since epoch)
|
||||||
|
or a relative string like "30d" (30 days) or "1y" (1 year).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
class Util:
|
||||||
|
"""Namespace of static helpers (kept as a class to preserve the original
|
||||||
|
``Util.method(...)`` call sites used throughout the plugin)."""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Locktime helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def locktime_to_str(locktime):
|
||||||
|
"""Render a locktime for display as an ISO date string."""
|
||||||
|
try:
|
||||||
|
locktime = int(locktime)
|
||||||
|
dt = datetime.fromtimestamp(locktime).isoformat()
|
||||||
|
return dt
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(locktime)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def str_to_locktime(locktime):
|
||||||
|
"""Parse a user-entered locktime string into its stored form.
|
||||||
|
|
||||||
|
Relative values keep their suffix (``"30d"``, ``"1y"``);
|
||||||
|
absolute ISO dates are converted to an integer UNIX timestamp.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if locktime[-1] in ("y", "d"):
|
||||||
|
return locktime
|
||||||
|
else:
|
||||||
|
return int(locktime)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
dt_object = datetime.fromisoformat(locktime)
|
||||||
|
timestamp = dt_object.timestamp()
|
||||||
|
return int(timestamp)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_locktime_string(locktime, now=None):
|
||||||
|
"""Resolve a (possibly relative) locktime string into a concrete int.
|
||||||
|
|
||||||
|
Supported forms:
|
||||||
|
* plain int / timestamp -> returned unchanged
|
||||||
|
* ``"<n>y"`` -> n years from now (as a timestamp)
|
||||||
|
* ``"<n>d"`` -> n days from now (as a timestamp)
|
||||||
|
|
||||||
|
When *now* is provided (a ``datetime``), relative strings are resolved
|
||||||
|
relative to that instant instead of ``datetime.now()``. This is used
|
||||||
|
when checking a signed will so that ``"30d"`` always resolves to the
|
||||||
|
*original* creation-time + 30 days, preventing spurious postpone
|
||||||
|
detections on every check.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return int(locktime)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now()
|
||||||
|
if locktime[-1] == "y":
|
||||||
|
locktime = str(int(locktime[:-1]) * 365) + "d"
|
||||||
|
if locktime[-1] == "d":
|
||||||
|
return int(
|
||||||
|
(now + timedelta(days=int(locktime[:-1])))
|
||||||
|
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
return int(locktime)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def int_locktime(seconds=0, minutes=0, hours=0, days=0):
|
||||||
|
"""Convert a human duration into seconds."""
|
||||||
|
return int(
|
||||||
|
seconds
|
||||||
|
+ minutes * 60
|
||||||
|
+ hours * 60 * 60
|
||||||
|
+ days * 60 * 60 * 24
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Amount helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def encode_amount(amount, decimal_point):
|
||||||
|
"""Convert a displayed BTC amount into integer satoshis.
|
||||||
|
|
||||||
|
Percentage amounts (e.g. ``"50%"``) are passed through unchanged, since
|
||||||
|
they are resolved later against the wallet balance.
|
||||||
|
"""
|
||||||
|
if Util.is_perc(amount):
|
||||||
|
return amount
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return int(float(amount) * pow(10, decimal_point))
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_amount(amount, decimal_point):
|
||||||
|
"""Inverse of :meth:`encode_amount`: satoshis -> displayed string."""
|
||||||
|
if Util.is_perc(amount):
|
||||||
|
return amount
|
||||||
|
else:
|
||||||
|
basestr = "{{:0.{}f}}".format(decimal_point)
|
||||||
|
try:
|
||||||
|
return basestr.format(float(amount) / pow(10, decimal_point))
|
||||||
|
except Exception:
|
||||||
|
return str(amount)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_perc(value):
|
||||||
|
"""True if ``value`` is a percentage string such as ``"25%"``."""
|
||||||
|
try:
|
||||||
|
return value[-1] == "%"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Heir / will-executor comparison helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def cmp_array(heira, heirb):
|
||||||
|
"""Element-wise equality of two sequences (length-safe)."""
|
||||||
|
try:
|
||||||
|
if len(heira) != len(heirb):
|
||||||
|
return False
|
||||||
|
for h in range(0, len(heira)):
|
||||||
|
if heira[h] != heirb[h]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_heir(heira, heirb):
|
||||||
|
"""Two heirs are "the same" when address (0) and amount (1) match."""
|
||||||
|
if heira[0] == heirb[0] and heira[1] == heirb[1]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_willexecutor(willexecutora, willexecutorb):
|
||||||
|
"""Compare two will-executor dicts by url / address / base_fee."""
|
||||||
|
if willexecutora == willexecutorb:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
willexecutora["url"] == willexecutorb["url"]
|
||||||
|
and willexecutora["address"] == willexecutorb["address"]
|
||||||
|
and willexecutora["base_fee"] == willexecutorb["base_fee"]
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_heir_by_values(heirs, heir, values):
|
||||||
|
"""Return the key of the first heir in ``heirs`` matching ``heir`` on
|
||||||
|
every column listed in ``values`` (or ``False`` if none)."""
|
||||||
|
for h, v in heirs.items():
|
||||||
|
found = False
|
||||||
|
for val in values:
|
||||||
|
if val in v and v[val] != heir[val]:
|
||||||
|
found = True
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
return h
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_heir_by_values(heira, heirb, values):
|
||||||
|
"""True when two heirs agree on every column index in ``values``."""
|
||||||
|
for v in values:
|
||||||
|
if heira[v] != heirb[v]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_heirs_by_values(
|
||||||
|
heirsa, heirsb, values, exclude_willexecutors=False, reverse=True
|
||||||
|
):
|
||||||
|
"""Set-equality of two heir collections, comparing only ``values``.
|
||||||
|
|
||||||
|
When ``exclude_willexecutors`` is set, synthetic will-executor heirs
|
||||||
|
(those whose key contains the ``w!ll3x3c"`` marker) are skipped. The
|
||||||
|
``reverse`` flag makes the comparison symmetric by running it both ways.
|
||||||
|
"""
|
||||||
|
for heira in heirsa:
|
||||||
|
if (
|
||||||
|
exclude_willexecutors and 'w!ll3x3c"' not in heira
|
||||||
|
) or not exclude_willexecutors:
|
||||||
|
found = False
|
||||||
|
for heirb in heirsb:
|
||||||
|
if Util.cmp_heir_by_values(heirsa[heira], heirsb[heirb], values):
|
||||||
|
found = True
|
||||||
|
if not found:
|
||||||
|
return False
|
||||||
|
if reverse:
|
||||||
|
return Util.cmp_heirs_by_values(
|
||||||
|
heirsb,
|
||||||
|
heirsa,
|
||||||
|
values,
|
||||||
|
exclude_willexecutors=exclude_willexecutors,
|
||||||
|
reverse=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_heirs(
|
||||||
|
heirsa,
|
||||||
|
heirsb,
|
||||||
|
cmp_function=lambda x, y: x[0] == y[0] and x[3] == y[3],
|
||||||
|
reverse=True,
|
||||||
|
):
|
||||||
|
"""Compare two heir collections using a custom ``cmp_function``.
|
||||||
|
|
||||||
|
Will-executor entries are ignored. As with
|
||||||
|
:meth:`cmp_heirs_by_values`, ``reverse`` makes the relation symmetric.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for heir in heirsa:
|
||||||
|
if 'w!ll3x3c"' not in heir:
|
||||||
|
if heir not in heirsb or not cmp_function(
|
||||||
|
heirsa[heir], heirsb[heir]
|
||||||
|
):
|
||||||
|
if not Util.search_heir_by_values(heirsb, heirsa[heir], [0, 3]):
|
||||||
|
return False
|
||||||
|
if reverse:
|
||||||
|
return Util.cmp_heirs(heirsb, heirsa, cmp_function, False)
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Transaction input/output comparison helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def cmp_inputs(inputsa, inputsb):
|
||||||
|
"""True when both input lists reference the same set of UTXOs."""
|
||||||
|
if len(inputsa) != len(inputsb):
|
||||||
|
return False
|
||||||
|
for inputa in inputsa:
|
||||||
|
if not Util.in_utxo(inputa, inputsb):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_outputs(outputsa, outputsb, willexecutor_output=None):
|
||||||
|
"""True when both output lists contain the same (address, value) pairs.
|
||||||
|
|
||||||
|
The optional ``willexecutor_output`` is treated as a wildcard match so
|
||||||
|
that the will-executor's fee output does not break the comparison.
|
||||||
|
"""
|
||||||
|
if len(outputsa) != len(outputsb):
|
||||||
|
return False
|
||||||
|
for outputa in outputsa:
|
||||||
|
if not Util.cmp_output(outputa, willexecutor_output):
|
||||||
|
if not Util.in_output(outputa, outputsb):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_txs(txa, txb):
|
||||||
|
"""Two transactions are equivalent when their inputs and outputs match."""
|
||||||
|
if not Util.cmp_inputs(txa.inputs(), txb.inputs()):
|
||||||
|
return False
|
||||||
|
if not Util.cmp_outputs(txa.outputs(), txb.outputs()):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_value_amount(txa, txb):
|
||||||
|
"""Sum of the values of outputs that appear (same addr+value) in both
|
||||||
|
transactions. Returns ``False`` as soon as an output of ``txa`` shares
|
||||||
|
neither amount nor address with any output of ``txb``."""
|
||||||
|
outputsa = txa.outputs()
|
||||||
|
value_amount = 0
|
||||||
|
|
||||||
|
for outa in outputsa:
|
||||||
|
same_amount, same_address = Util.din_output(outa, txb.outputs())
|
||||||
|
if not (same_amount or same_address):
|
||||||
|
return False
|
||||||
|
if same_amount and same_address:
|
||||||
|
value_amount += outa.value
|
||||||
|
if same_amount:
|
||||||
|
pass
|
||||||
|
if same_address:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return value_amount
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Locktime arithmetic
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def chk_locktime(timestamp_to_check, locktime):
|
||||||
|
"""Return True if ``locktime`` is still in the future."""
|
||||||
|
locktime = int(locktime)
|
||||||
|
return locktime > timestamp_to_check
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def anticipate_locktime(locktime, hours=0, days=0):
|
||||||
|
"""Move a locktime earlier by the given amount (only timestamp locktimes).
|
||||||
|
|
||||||
|
Never returns a value below 1.
|
||||||
|
"""
|
||||||
|
locktime = int(locktime)
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(locktime)
|
||||||
|
except (OverflowError, OSError, ValueError):
|
||||||
|
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
|
||||||
|
dt -= timedelta(seconds=hours * 3600 + days * 86400)
|
||||||
|
out = dt.timestamp()
|
||||||
|
if out < 1:
|
||||||
|
out = 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_locktime(locktimea, locktimeb):
|
||||||
|
"""Compare two relative locktime strings sharing the same unit."""
|
||||||
|
if locktimea == locktimeb:
|
||||||
|
return 0
|
||||||
|
strlocktimea = str(locktimea)
|
||||||
|
strlocktimeb = str(locktimeb)
|
||||||
|
if locktimea[-1] in "yd":
|
||||||
|
if locktimeb[-1] == locktimea[-1]:
|
||||||
|
return int(strlocktimea[-1]) - int(strlocktimeb[-1])
|
||||||
|
else:
|
||||||
|
return int(locktimea) - (locktimeb)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_locktime_increased(old, new):
|
||||||
|
"""True when *new* locktime spec is longer/greater than *old*."""
|
||||||
|
def _to_days(v):
|
||||||
|
if isinstance(v, str) and v[-1:] in ("d", "y"):
|
||||||
|
n = int(v[:-1])
|
||||||
|
return n * 365 if v[-1] == "y" else n
|
||||||
|
return int(v)
|
||||||
|
return _to_days(new) > _to_days(old)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_locktimes(will):
|
||||||
|
"""Return the distinct locktimes used by the transactions in ``will``."""
|
||||||
|
locktimes = {}
|
||||||
|
for txid, willitem in will.items():
|
||||||
|
locktimes[willitem["tx"].locktime] = True
|
||||||
|
return locktimes.keys()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_lowest_locktimes(locktimes):
|
||||||
|
"""Return sorted list of timestamp locktimes."""
|
||||||
|
sorted_timestamps = []
|
||||||
|
for locktime in locktimes:
|
||||||
|
locktime = Util.parse_locktime_string(locktime)
|
||||||
|
if locktime is not None:
|
||||||
|
sorted_timestamps.append(locktime)
|
||||||
|
return sorted(sorted_timestamps)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_willtx_per_io(will, tx):
|
||||||
|
"""Find a will entry whose tx has the same inputs/outputs as ``tx``."""
|
||||||
|
for wid, w in will.items():
|
||||||
|
if Util.cmp_txs(w["tx"], tx["tx"]):
|
||||||
|
return wid, w
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def invalidate_will(will):
|
||||||
|
raise Exception("not implemented")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_will_spent_utxos(will):
|
||||||
|
"""Collect every input spent by any transaction in ``will``."""
|
||||||
|
utxos = []
|
||||||
|
for txid, willitem in will.items():
|
||||||
|
utxos += willitem["tx"].inputs()
|
||||||
|
|
||||||
|
return utxos
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# UTXO helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def utxo_to_str(utxo):
|
||||||
|
"""Best-effort conversion of a UTXO / input object to its ``txid:n`` str."""
|
||||||
|
try:
|
||||||
|
return utxo.to_str()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return utxo.prevout.to_str()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(utxo)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_utxo(utxoa, utxob):
|
||||||
|
"""True when two UTXOs refer to the same outpoint."""
|
||||||
|
utxoa = Util.utxo_to_str(utxoa)
|
||||||
|
utxob = Util.utxo_to_str(utxob)
|
||||||
|
if utxoa == utxob:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def in_utxo(utxo, utxos):
|
||||||
|
"""Membership test for a UTXO inside an iterable of UTXOs."""
|
||||||
|
for s_u in utxos:
|
||||||
|
if Util.cmp_utxo(s_u, utxo):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def txid_in_utxo(txid, utxos):
|
||||||
|
"""True if any UTXO in ``utxos`` is spent from transaction ``txid``."""
|
||||||
|
for s_u in utxos:
|
||||||
|
if s_u.prevout.txid == txid:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmp_output(outputa, outputb):
|
||||||
|
"""Two outputs are equal when both address and value match."""
|
||||||
|
return outputa.address == outputb.address and outputa.value == outputb.value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def in_output(output, outputs):
|
||||||
|
"""Membership test for an output inside an iterable of outputs."""
|
||||||
|
for s_o in outputs:
|
||||||
|
if Util.cmp_output(s_o, output):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# check all output with the same amount if none have the same address it can be a change
|
||||||
|
# return true true same address same amount
|
||||||
|
# return true false same amount different address
|
||||||
|
# return false false different amount, different address not found
|
||||||
|
@staticmethod
|
||||||
|
def din_output(out, outputs):
|
||||||
|
"""Detailed output lookup used to tell a change output apart.
|
||||||
|
|
||||||
|
Returns a ``(same_amount, same_address)`` tuple:
|
||||||
|
* ``(True, True)`` -> an output with same amount *and* address
|
||||||
|
* ``(True, False)`` -> same amount but different address (maybe change)
|
||||||
|
* ``(False, False)``-> no output with this amount
|
||||||
|
"""
|
||||||
|
same_amount = []
|
||||||
|
for s_o in outputs:
|
||||||
|
if int(out.value) == int(s_o.value):
|
||||||
|
same_amount.append(s_o)
|
||||||
|
if out.address == s_o.address:
|
||||||
|
return True, True
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(same_amount) > 0:
|
||||||
|
return True, False
|
||||||
|
else:
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_change_output(wallet, in_amount, out_amount, fee):
|
||||||
|
"""Build a change ``PartialTxOutput`` if the leftover exceeds dust."""
|
||||||
|
change_amount = int(in_amount - out_amount - fee)
|
||||||
|
if change_amount > wallet.dust_threshold():
|
||||||
|
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||||
|
out = PartialTxOutput.from_address_and_value(
|
||||||
|
change_addresses[0], change_amount
|
||||||
|
)
|
||||||
|
out.is_change = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_current_height(network):
|
||||||
|
"""Return the current UNIX timestamp for locktime purposes.
|
||||||
|
|
||||||
|
Returns time.time() as the reference timestamp.
|
||||||
|
"""
|
||||||
|
return int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Misc helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@staticmethod
|
||||||
|
def copy(dicto, dictfrom):
|
||||||
|
"""Shallow copy of ``dictfrom`` entries into ``dicto`` (in place)."""
|
||||||
|
for k, v in dictfrom.items():
|
||||||
|
dicto[k] = v
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fix_will_settings_tx_fees(will_settings):
|
||||||
|
"""Migrate the legacy ``tx_fees`` key to ``baltx_fees`` in settings.
|
||||||
|
|
||||||
|
Returns True when a migration was performed (caller should persist).
|
||||||
|
"""
|
||||||
|
tx_fees = will_settings.get("tx_fees", False)
|
||||||
|
have_to_update = False
|
||||||
|
if tx_fees:
|
||||||
|
will_settings["baltx_fees"] = tx_fees
|
||||||
|
del will_settings["tx_fees"]
|
||||||
|
have_to_update = True
|
||||||
|
return have_to_update
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fix_will_tx_fees(will):
|
||||||
|
"""Same legacy migration as above but applied to every will entry."""
|
||||||
|
have_to_update = False
|
||||||
|
for txid, willitem in will.items():
|
||||||
|
tx_fees = willitem.get("tx_fees", False)
|
||||||
|
if tx_fees:
|
||||||
|
will[txid]["baltx_fees"] = tx_fees
|
||||||
|
del will[txid]["tx_fees"]
|
||||||
|
have_to_update = True
|
||||||
|
return have_to_update
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def text_to_hex(text: str) -> str:
|
||||||
|
"""Convert text to a hexadecimal string (used for OP_RETURN payloads)."""
|
||||||
|
hex_string = text.encode('utf-8').hex()
|
||||||
|
return hex_string
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hex_to_text(hex_string: str) -> str:
|
||||||
|
"""Convert a hexadecimal string back to text (for verification)."""
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(hex_string).decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
return "Error: Invalid hex string"
|
||||||
1149
bal/core/will.py
Normal file
1149
bal/core/will.py
Normal file
File diff suppressed because it is too large
Load Diff
788
bal/core/willexecutors.py
Normal file
788
bal/core/willexecutors.py
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
"""
|
||||||
|
bal.core.willexecutors
|
||||||
|
=======================
|
||||||
|
|
||||||
|
Client logic for talking to *will-executor* servers.
|
||||||
|
|
||||||
|
A will-executor is an optional third-party service that, for a small fee,
|
||||||
|
stores the signed inheritance transactions off-line and broadcasts them once
|
||||||
|
their locktime expires (acting as a dead-man's switch backup).
|
||||||
|
|
||||||
|
This module only contains the networking / data-shaping logic (downloading the
|
||||||
|
server list, pinging servers for their fee and address, pushing transactions,
|
||||||
|
checking whether a tx is already stored). It is GUI-free: all user
|
||||||
|
interaction is handled by the Qt layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiohttp import ClientResponse
|
||||||
|
from electrum.i18n import _
|
||||||
|
from electrum.logging import get_logger
|
||||||
|
from electrum.network import Network
|
||||||
|
|
||||||
|
from .plugin_base import BalPlugin
|
||||||
|
|
||||||
|
# Per-request timeout (seconds) for interactive operations (ping / info /
|
||||||
|
# list download). These fail fast (no retries) so a dead server does not
|
||||||
|
# block the UI.
|
||||||
|
DEFAULT_TIMEOUT = 5
|
||||||
|
|
||||||
|
# Broadcast (pushtxs) timeouts. Broadcasting a will is important, so we keep a
|
||||||
|
# couple of quick retries to survive a transient hiccup -- but far from the old
|
||||||
|
# 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server.
|
||||||
|
# Worst case per server is now ~ PUSH_TIMEOUT * (1 + PUSH_MAX_RETRIES)
|
||||||
|
# + PUSH_RETRY_SLEEP * PUSH_MAX_RETRIES = 8 * 3 + 1 * 2 = ~26s, and the wizard
|
||||||
|
# also enforces a global deadline on top of this (see push_transactions_parallel).
|
||||||
|
PUSH_TIMEOUT = 8
|
||||||
|
PUSH_MAX_RETRIES = 2
|
||||||
|
PUSH_RETRY_SLEEP = 1
|
||||||
|
|
||||||
|
# Global wall-clock deadline (seconds) for the whole parallel broadcast. Once
|
||||||
|
# it elapses we stop waiting for the still-pending servers, mark them as
|
||||||
|
# "Timeout" and let the wizard proceed instead of appearing stuck.
|
||||||
|
PUSH_GLOBAL_DEADLINE = 30
|
||||||
|
|
||||||
|
# Check (searchtx) timeouts. Used when the user presses "Check" to verify that
|
||||||
|
# each will-executor still holds the transaction. Like the broadcast path, the
|
||||||
|
# old defaults (10s x 10 retries + 30s sleeps ~= 140s per server) froze the
|
||||||
|
# "checking transaction" dialog on a single dead server. Fail fast with one
|
||||||
|
# quick retry, and cap the whole batch with a global deadline.
|
||||||
|
CHECK_TIMEOUT = 8
|
||||||
|
CHECK_MAX_RETRIES = 1
|
||||||
|
CHECK_RETRY_SLEEP = 1
|
||||||
|
CHECK_GLOBAL_DEADLINE = 30
|
||||||
|
|
||||||
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
chainname = BalPlugin.chainname
|
||||||
|
|
||||||
|
|
||||||
|
class Willexecutors:
|
||||||
|
|
||||||
|
# Expose the networking constants as class attributes so the GUI layer can
|
||||||
|
# reference them (e.g. to show the "Xs / DEADLINEs" countdown) without
|
||||||
|
# importing module-level names. Single source of truth: the module
|
||||||
|
# constants defined above.
|
||||||
|
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT
|
||||||
|
PUSH_TIMEOUT = PUSH_TIMEOUT
|
||||||
|
PUSH_MAX_RETRIES = PUSH_MAX_RETRIES
|
||||||
|
PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP
|
||||||
|
PUSH_GLOBAL_DEADLINE = PUSH_GLOBAL_DEADLINE
|
||||||
|
CHECK_TIMEOUT = CHECK_TIMEOUT
|
||||||
|
CHECK_MAX_RETRIES = CHECK_MAX_RETRIES
|
||||||
|
CHECK_RETRY_SLEEP = CHECK_RETRY_SLEEP
|
||||||
|
CHECK_GLOBAL_DEADLINE = CHECK_GLOBAL_DEADLINE
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save(bal_plugin, willexecutors):
|
||||||
|
_logger.debug(f"save {willexecutors},{chainname}")
|
||||||
|
aw = bal_plugin.WILLEXECUTORS.get()
|
||||||
|
aw[chainname] = willexecutors
|
||||||
|
bal_plugin.WILLEXECUTORS.set(aw)
|
||||||
|
_logger.debug(f"saved: {aw}")
|
||||||
|
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_willexecutors(
|
||||||
|
bal_plugin, update=False, bal_window=False, force=False, task=True
|
||||||
|
):
|
||||||
|
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
||||||
|
willexecutors = willexecutors.get(chainname, {})
|
||||||
|
to_del = []
|
||||||
|
for w in willexecutors:
|
||||||
|
if not isinstance(willexecutors[w], dict):
|
||||||
|
to_del.append(w)
|
||||||
|
continue
|
||||||
|
Willexecutors.initialize_willexecutor(willexecutors[w], w)
|
||||||
|
for w in to_del:
|
||||||
|
_logger.error(
|
||||||
|
"error Willexecutor to delete type:{} {}".format(
|
||||||
|
type(willexecutors[w]), w
|
||||||
|
)
|
||||||
|
)
|
||||||
|
del willexecutors[w]
|
||||||
|
bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {})
|
||||||
|
for bal_url, bal_executor in bal.items():
|
||||||
|
if bal_url not in willexecutors:
|
||||||
|
_logger.debug(f"force add {bal_url} willexecutor")
|
||||||
|
willexecutors[bal_url] = bal_executor
|
||||||
|
# if update:
|
||||||
|
# found = False
|
||||||
|
# for url, we in willexecutors.items():
|
||||||
|
# if Willexecutors.is_selected(we):
|
||||||
|
# found = True
|
||||||
|
# if found or force:
|
||||||
|
# if bal_plugin.PING_WILLEXECUTORS.get() or force:
|
||||||
|
# ping_willexecutors = True
|
||||||
|
# if bal_plugin.ASK_PING_WILLEXECUTORS.get() and not force:
|
||||||
|
# if bal_window:
|
||||||
|
# ping_willexecutors = bal_window.window.question(
|
||||||
|
# _(
|
||||||
|
# "Contact willexecutors servers to update payment informations?"
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
# if ping_willexecutors:
|
||||||
|
# if task:
|
||||||
|
# bal_window.ping_willexecutors(willexecutors, task)
|
||||||
|
# else:
|
||||||
|
# bal_window.ping_willexecutors_task(willexecutors)
|
||||||
|
w_sorted = dict(
|
||||||
|
sorted(
|
||||||
|
willexecutors.items(), key=lambda w: w[1].get("sort", 0), reverse=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return w_sorted
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_selected(willexecutor, value=None):
|
||||||
|
if not willexecutor:
|
||||||
|
return False
|
||||||
|
if value is not None:
|
||||||
|
willexecutor["selected"] = value
|
||||||
|
try:
|
||||||
|
return willexecutor["selected"]
|
||||||
|
except Exception:
|
||||||
|
willexecutor["selected"] = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_willexecutor_transactions(will, force=False):
|
||||||
|
willexecutors = {}
|
||||||
|
for wid, willitem in will.items():
|
||||||
|
if willitem.get_status("VALID"):
|
||||||
|
if willitem.get_status("COMPLETE"):
|
||||||
|
if not willitem.get_status("PUSHED") or force:
|
||||||
|
if willexecutor := willitem.we:
|
||||||
|
url = willexecutor["url"]
|
||||||
|
if willexecutor and Willexecutors.is_selected(willexecutor):
|
||||||
|
if url not in willexecutors:
|
||||||
|
willexecutor["txs"] = ""
|
||||||
|
willexecutor["txsids"] = []
|
||||||
|
willexecutor["broadcast_status"] = _("Waiting...")
|
||||||
|
willexecutors[url] = willexecutor
|
||||||
|
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
|
||||||
|
willexecutors[url]["txsids"].append(wid)
|
||||||
|
|
||||||
|
return willexecutors
|
||||||
|
|
||||||
|
# def only_selected_list(willexecutors):
|
||||||
|
# out = {}
|
||||||
|
# for url, v in willexecutors.items():
|
||||||
|
# if Willexecutors.is_selected(url):
|
||||||
|
# out[url] = v
|
||||||
|
|
||||||
|
# def push_transactions_to_willexecutors(will):
|
||||||
|
# willexecutors = Willexecutors.get_transactions_to_be_pushed()
|
||||||
|
# for url in willexecutors:
|
||||||
|
# willexecutor = willexecutors[url]
|
||||||
|
# if Willexecutors.is_selected(willexecutor):
|
||||||
|
# if "txs" in willexecutor:
|
||||||
|
# Willexecutors.push_transactions_to_willexecutor(
|
||||||
|
# willexecutors[url]["txs"], url
|
||||||
|
# )
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_request(
|
||||||
|
method, url, data=None, *, timeout=10, handle_response=None, count_reply=0,
|
||||||
|
max_retries=10, retry_sleep=3,
|
||||||
|
):
|
||||||
|
"""Send an HTTP request to a will-executor server.
|
||||||
|
|
||||||
|
``max_retries`` / ``retry_sleep`` control the timeout-retry behaviour:
|
||||||
|
|
||||||
|
* For *critical* operations (pushing inheritance transactions) the
|
||||||
|
historical default of up to 10 retries with a 3s back-off is kept, so
|
||||||
|
a transient network hiccup does not lose a transaction.
|
||||||
|
* For *interactive* operations (ping / info / list download) callers
|
||||||
|
should pass ``max_retries=0`` so a dead server fails fast (one short
|
||||||
|
timeout) instead of blocking the UI for minutes. See
|
||||||
|
:meth:`ping_servers_parallel`.
|
||||||
|
"""
|
||||||
|
network = Network.get_instance()
|
||||||
|
if not network:
|
||||||
|
raise Exception("You are offline.")
|
||||||
|
_logger.debug(f"<-- {method} {url} {data}")
|
||||||
|
headers = {}
|
||||||
|
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}"
|
||||||
|
headers["Content-Type"] = "text/plain"
|
||||||
|
if not handle_response:
|
||||||
|
handle_response = Willexecutors.handle_response
|
||||||
|
try:
|
||||||
|
if method == "get":
|
||||||
|
response = Network.send_http_on_proxy(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
params=data,
|
||||||
|
headers=headers,
|
||||||
|
on_finish=handle_response,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
elif method == "post":
|
||||||
|
response = Network.send_http_on_proxy(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
body=data,
|
||||||
|
headers=headers,
|
||||||
|
on_finish=handle_response,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise Exception(f"unexpected {method=!r}")
|
||||||
|
except TimeoutError:
|
||||||
|
if count_reply < max_retries:
|
||||||
|
_logger.debug(
|
||||||
|
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
|
||||||
|
)
|
||||||
|
if retry_sleep:
|
||||||
|
time.sleep(retry_sleep)
|
||||||
|
return Willexecutors.send_request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
timeout=timeout,
|
||||||
|
handle_response=handle_response,
|
||||||
|
count_reply=count_reply + 1,
|
||||||
|
max_retries=max_retries,
|
||||||
|
retry_sleep=retry_sleep,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_logger.debug(f"Too many timeouts: {count_reply}")
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
else:
|
||||||
|
_logger.debug(f"--> {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_we_url_from_response(resp):
|
||||||
|
url_slices = str(resp.url).split("/")
|
||||||
|
if len(url_slices) > 2:
|
||||||
|
url_slices = url_slices[:-2]
|
||||||
|
return "/".join(url_slices)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_response(resp: ClientResponse):
|
||||||
|
r = await resp.text()
|
||||||
|
try:
|
||||||
|
|
||||||
|
r = json.loads(r)
|
||||||
|
# url = Willexecutors.get_we_url_from_response(resp)
|
||||||
|
# r["url"]= url
|
||||||
|
# r["status"]=resp.status
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f"error handling response:{e}")
|
||||||
|
pass
|
||||||
|
return r
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
class AlreadyPresentException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def push_transactions_to_willexecutor(
|
||||||
|
willexecutor, *, timeout=PUSH_TIMEOUT, max_retries=PUSH_MAX_RETRIES,
|
||||||
|
retry_sleep=PUSH_RETRY_SLEEP,
|
||||||
|
):
|
||||||
|
# ``timeout`` / ``max_retries`` / ``retry_sleep`` are forwarded to
|
||||||
|
# send_request so the broadcast fails fast on a dead/slow server instead
|
||||||
|
# of hanging for ~140s (the old default was 10s timeout x 10 retries +
|
||||||
|
# 30s of sleeps). A small number of quick retries still protects
|
||||||
|
# against a transient hiccup without freezing the wizard.
|
||||||
|
out = True
|
||||||
|
try:
|
||||||
|
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
||||||
|
if w := Willexecutors.send_request(
|
||||||
|
"post",
|
||||||
|
willexecutor["url"] + "/" + chainname + "/pushtxs",
|
||||||
|
data=willexecutor["txs"].encode("ascii"),
|
||||||
|
timeout=timeout,
|
||||||
|
max_retries=max_retries,
|
||||||
|
retry_sleep=retry_sleep,
|
||||||
|
):
|
||||||
|
willexecutor["broadcast_status"] = _("Success")
|
||||||
|
_logger.debug(f"pushed: {w}")
|
||||||
|
if w != "thx":
|
||||||
|
_logger.debug(f"error: {w}")
|
||||||
|
raise Exception(w)
|
||||||
|
else:
|
||||||
|
raise Exception("empty reply from:{willexecutor['url']}")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f"error:{e}")
|
||||||
|
if str(e) == "already present":
|
||||||
|
raise Willexecutors.AlreadyPresentException()
|
||||||
|
out = False
|
||||||
|
willexecutor["broadcast_status"] = _("Failed")
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ping_servers(willexecutors):
|
||||||
|
for url, we in willexecutors.items():
|
||||||
|
Willexecutors.get_info_task(url, we)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_info_task(url, willexecutor, *, timeout=DEFAULT_TIMEOUT,
|
||||||
|
max_retries=0, retry_sleep=0):
|
||||||
|
w = None
|
||||||
|
try:
|
||||||
|
_logger.info("GETINFO_WILLEXECUTOR")
|
||||||
|
_logger.debug(url)
|
||||||
|
# Fast-fail by default (max_retries=0): a dead server returns after a
|
||||||
|
# single short timeout instead of retrying 10x with sleeps, which
|
||||||
|
# used to freeze the UI for minutes per unreachable server.
|
||||||
|
w = Willexecutors.send_request(
|
||||||
|
"get", url + "/" + chainname + "/info",
|
||||||
|
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||||
|
)
|
||||||
|
if isinstance(w, dict):
|
||||||
|
willexecutor["url"] = url
|
||||||
|
willexecutor["status"] = 200
|
||||||
|
willexecutor["base_fee"] = w["base_fee"]
|
||||||
|
willexecutor["address"] = w["address"]
|
||||||
|
willexecutor["info"] = w["info"]
|
||||||
|
else:
|
||||||
|
# No dict reply (timeout / empty) -> mark as unreachable.
|
||||||
|
willexecutor["status"] = "KO"
|
||||||
|
_logger.debug(f"response_data {w}")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"error {e} contacting {url}: {w}")
|
||||||
|
willexecutor["status"] = "KO"
|
||||||
|
|
||||||
|
willexecutor["last_update"] = datetime.now().timestamp()
|
||||||
|
return willexecutor
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8,
|
||||||
|
timeout=DEFAULT_TIMEOUT, on_tick=None,
|
||||||
|
tick_interval=1.0):
|
||||||
|
"""Ping every will-executor concurrently and report results as they
|
||||||
|
arrive.
|
||||||
|
|
||||||
|
Network requests run in a thread pool: each ``send_http_on_proxy`` call
|
||||||
|
schedules its coroutine on Electrum's shared asyncio loop and blocks
|
||||||
|
only its *own* worker thread, so the total wall-clock time is roughly
|
||||||
|
that of the slowest server rather than the *sum* of all of them. A
|
||||||
|
single dead server can no longer stall the whole batch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
willexecutors: ``{url: we_dict}`` mapping (mutated in place with the
|
||||||
|
ping result, exactly like the old sequential ``ping_servers``).
|
||||||
|
on_each: optional ``callback(url, we_dict, ok: bool)`` invoked from a
|
||||||
|
worker thread each time a server answers (or fails), so the GUI
|
||||||
|
can update its list live. Must be thread-safe / marshalled to
|
||||||
|
the GUI thread by the caller.
|
||||||
|
max_workers: maximum number of concurrent pings.
|
||||||
|
timeout: per-request timeout in seconds (fast-fail, no retries).
|
||||||
|
|
||||||
|
on_tick: optional ``callback()`` invoked periodically (every
|
||||||
|
``tick_interval`` seconds) **from the calling thread** while
|
||||||
|
waiting for servers, so a Qt caller can refresh an elapsed-time
|
||||||
|
counter from the same thread that drives ``on_each``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The same ``willexecutors`` mapping, updated in place.
|
||||||
|
"""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from concurrent.futures import FIRST_COMPLETED
|
||||||
|
|
||||||
|
items = list(willexecutors.items())
|
||||||
|
if not items:
|
||||||
|
return willexecutors
|
||||||
|
|
||||||
|
def _ping_one(url, we):
|
||||||
|
we = Willexecutors.get_info_task(
|
||||||
|
url, we, timeout=timeout, max_retries=0, retry_sleep=0
|
||||||
|
)
|
||||||
|
ok = we.get("status") == 200
|
||||||
|
return url, we, ok
|
||||||
|
|
||||||
|
def _fire_tick():
|
||||||
|
if on_tick is not None:
|
||||||
|
try:
|
||||||
|
on_tick()
|
||||||
|
except Exception as cb_err:
|
||||||
|
_logger.error(f"ping on_tick callback error: {cb_err}")
|
||||||
|
|
||||||
|
workers = max(1, min(max_workers, len(items)))
|
||||||
|
# Manual pool (no ``with``) so we can poll futures in short slices and
|
||||||
|
# drive ``on_tick`` from THIS thread between waits (reliable Qt repaint).
|
||||||
|
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-ping")
|
||||||
|
futures = {pool.submit(_ping_one, url, we) for url, we in items}
|
||||||
|
try:
|
||||||
|
pending = set(futures)
|
||||||
|
while pending:
|
||||||
|
done, pending = wait(
|
||||||
|
pending, timeout=tick_interval, return_when=FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
for fut in done:
|
||||||
|
try:
|
||||||
|
url, we, ok = fut.result()
|
||||||
|
except Exception as e: # defensive: one server never crashes all
|
||||||
|
_logger.error(f"ping_servers_parallel worker error: {e}")
|
||||||
|
continue
|
||||||
|
willexecutors[url] = we
|
||||||
|
if on_each is not None:
|
||||||
|
try:
|
||||||
|
on_each(url, we, ok)
|
||||||
|
except Exception as cb_err:
|
||||||
|
_logger.error(f"ping on_each callback error: {cb_err}")
|
||||||
|
# Drive the elapsed-time counter from the calling thread.
|
||||||
|
_fire_tick()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
except TypeError:
|
||||||
|
pool.shutdown(wait=False)
|
||||||
|
return willexecutors
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8,
|
||||||
|
deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None,
|
||||||
|
on_tick=None, tick_interval=1.0):
|
||||||
|
"""Push transactions to multiple will-executors concurrently.
|
||||||
|
|
||||||
|
Like :meth:`ping_servers_parallel` but for the ``pushtxs`` operation.
|
||||||
|
Each server keeps a short retry behaviour
|
||||||
|
(:meth:`push_transactions_to_willexecutor`) so a real transaction is not
|
||||||
|
lost to a transient hiccup, but servers are contacted in parallel and
|
||||||
|
results are reported via ``on_each(url, we_dict, ok, exc)`` as they
|
||||||
|
complete.
|
||||||
|
|
||||||
|
A global wall-clock ``deadline`` (seconds) caps the whole operation: if
|
||||||
|
some servers are still pending when it elapses, we stop waiting, mark
|
||||||
|
them via ``on_timeout(url, we_dict)`` and return, so the caller (the
|
||||||
|
wizard) is never stuck behind one unresponsive server. Pass
|
||||||
|
``deadline=None`` to wait indefinitely (old behaviour).
|
||||||
|
|
||||||
|
``on_tick()`` is invoked periodically (every ``tick_interval`` seconds)
|
||||||
|
**from the calling thread** while waiting for workers. This lets a Qt
|
||||||
|
caller refresh an elapsed-time counter from the same thread that drives
|
||||||
|
``on_each`` (so its pyqtSignal repaints reliably), instead of relying on
|
||||||
|
a separate heartbeat thread whose signal emissions are not marshalled.
|
||||||
|
|
||||||
|
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
||||||
|
answered in time (timed-out servers are reported via ``on_timeout``).
|
||||||
|
"""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from concurrent.futures import FIRST_COMPLETED
|
||||||
|
|
||||||
|
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
||||||
|
results = {}
|
||||||
|
if not targets:
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _push_one(url, we):
|
||||||
|
try:
|
||||||
|
ok = Willexecutors.push_transactions_to_willexecutor(we)
|
||||||
|
return url, we, ok, None
|
||||||
|
except Willexecutors.AlreadyPresentException as ape:
|
||||||
|
return url, we, False, ape
|
||||||
|
except Exception as e:
|
||||||
|
return url, we, False, e
|
||||||
|
|
||||||
|
def _fire_tick():
|
||||||
|
if on_tick is not None:
|
||||||
|
try:
|
||||||
|
on_tick()
|
||||||
|
except Exception as cb_err:
|
||||||
|
_logger.error(f"push on_tick callback error: {cb_err}")
|
||||||
|
|
||||||
|
workers = max(1, min(max_workers, len(targets)))
|
||||||
|
# NOTE: we do not use ``with ThreadPoolExecutor(...)`` here because its
|
||||||
|
# __exit__ calls shutdown(wait=True), which would block on a hung worker
|
||||||
|
# and defeat the whole point of the global deadline. We shut the pool
|
||||||
|
# down without waiting once the deadline elapses; the daemon worker(s)
|
||||||
|
# stuck on a dead socket will be torn down when their request finally
|
||||||
|
# times out (PUSH_TIMEOUT), without holding up the wizard.
|
||||||
|
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-push")
|
||||||
|
fut_to_url = {pool.submit(_push_one, url, we): (url, we)
|
||||||
|
for url, we in targets}
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
# Poll the futures in short slices so we can call ``on_tick`` from
|
||||||
|
# THIS thread between waits. ``wait(..., timeout=tick_interval)``
|
||||||
|
# returns as soon as a future completes OR the slice elapses,
|
||||||
|
# whichever comes first, so the counter advances ~once per second
|
||||||
|
# while the parallel push runs.
|
||||||
|
pending = set(fut_to_url.keys())
|
||||||
|
while pending:
|
||||||
|
if deadline is not None and (time.time() - start) >= deadline:
|
||||||
|
break
|
||||||
|
slice_timeout = tick_interval
|
||||||
|
if deadline is not None:
|
||||||
|
remaining = deadline - (time.time() - start)
|
||||||
|
slice_timeout = max(0.0, min(tick_interval, remaining))
|
||||||
|
done, pending = wait(
|
||||||
|
pending, timeout=slice_timeout, return_when=FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
for fut in done:
|
||||||
|
try:
|
||||||
|
url, we, ok, exc = fut.result()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
f"push_transactions_parallel worker error: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
results[url] = (ok, exc)
|
||||||
|
if on_each is not None:
|
||||||
|
try:
|
||||||
|
on_each(url, we, ok, exc)
|
||||||
|
except Exception as cb_err:
|
||||||
|
_logger.error(f"push on_each callback error: {cb_err}")
|
||||||
|
# Drive the elapsed-time counter from the calling thread.
|
||||||
|
_fire_tick()
|
||||||
|
# Any server still pending here hit the global deadline.
|
||||||
|
if pending:
|
||||||
|
elapsed = time.time() - start
|
||||||
|
_logger.warning(
|
||||||
|
f"push global deadline ({deadline}s) reached after "
|
||||||
|
f"{elapsed:.1f}s; {len(pending)} server(s) "
|
||||||
|
f"did not answer in time"
|
||||||
|
)
|
||||||
|
for fut in pending:
|
||||||
|
url, we = fut_to_url[fut]
|
||||||
|
if url in results:
|
||||||
|
continue
|
||||||
|
if on_timeout is not None:
|
||||||
|
try:
|
||||||
|
on_timeout(url, we)
|
||||||
|
except Exception as cb_err:
|
||||||
|
_logger.error(
|
||||||
|
f"push on_timeout callback error: {cb_err}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Do not block on still-running workers (Python 3.9+: cancel queued).
|
||||||
|
try:
|
||||||
|
pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
except TypeError:
|
||||||
|
pool.shutdown(wait=False)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_transactions_parallel(items, *, on_each=None, max_workers=8,
|
||||||
|
deadline=CHECK_GLOBAL_DEADLINE,
|
||||||
|
on_timeout=None, on_tick=None,
|
||||||
|
tick_interval=1.0):
|
||||||
|
"""Check (searchtx) several will-executors concurrently.
|
||||||
|
|
||||||
|
Same design as :meth:`push_transactions_parallel`, but for the "Check"
|
||||||
|
operation: it verifies that each will-executor still holds its
|
||||||
|
transaction. ``items`` is an iterable of ``(wid, url)`` pairs (one per
|
||||||
|
will-item that has a will-executor).
|
||||||
|
|
||||||
|
Each server is contacted in parallel with a short fail-fast retry
|
||||||
|
(:meth:`check_transaction`), results are reported via
|
||||||
|
``on_each(wid, url, result_or_None, exc)`` as they arrive, ``on_tick()``
|
||||||
|
is called periodically from the calling thread to refresh a counter, and
|
||||||
|
a global ``deadline`` guarantees the dialog never freezes behind one
|
||||||
|
unresponsive server (pending servers are reported via
|
||||||
|
``on_timeout(wid, url)``).
|
||||||
|
|
||||||
|
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
||||||
|
that answered in time.
|
||||||
|
"""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from concurrent.futures import FIRST_COMPLETED
|
||||||
|
|
||||||
|
targets = [(wid, url) for wid, url in items if url]
|
||||||
|
results = {}
|
||||||
|
if not targets:
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _check_one(wid, url):
|
||||||
|
try:
|
||||||
|
res = Willexecutors.check_transaction(wid, url)
|
||||||
|
return wid, url, res, None
|
||||||
|
except Exception as e:
|
||||||
|
return wid, url, None, e
|
||||||
|
|
||||||
|
def _fire_tick():
|
||||||
|
if on_tick is not None:
|
||||||
|
try:
|
||||||
|
on_tick()
|
||||||
|
except Exception as cb_err:
|
||||||
|
_logger.error(f"check on_tick callback error: {cb_err}")
|
||||||
|
|
||||||
|
workers = max(1, min(max_workers, len(targets)))
|
||||||
|
# Manual pool (no ``with``): we must not block on a hung worker when the
|
||||||
|
# global deadline elapses (see push_transactions_parallel for details).
|
||||||
|
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-check")
|
||||||
|
fut_to_target = {pool.submit(_check_one, wid, url): (wid, url)
|
||||||
|
for wid, url in targets}
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
pending = set(fut_to_target.keys())
|
||||||
|
while pending:
|
||||||
|
if deadline is not None and (time.time() - start) >= deadline:
|
||||||
|
break
|
||||||
|
slice_timeout = tick_interval
|
||||||
|
if deadline is not None:
|
||||||
|
remaining = deadline - (time.time() - start)
|
||||||
|
slice_timeout = max(0.0, min(tick_interval, remaining))
|
||||||
|
done, pending = wait(
|
||||||
|
pending, timeout=slice_timeout, return_when=FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
for fut in done:
|
||||||
|
try:
|
||||||
|
wid, url, res, exc = fut.result()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
f"check_transactions_parallel worker error: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
results[wid] = (res, exc)
|
||||||
|
if on_each is not None:
|
||||||
|
try:
|
||||||
|
on_each(wid, url, res, exc)
|
||||||
|
except Exception as cb_err:
|
||||||
|
_logger.error(f"check on_each callback error: {cb_err}")
|
||||||
|
# Drive the elapsed-time counter from the calling thread.
|
||||||
|
_fire_tick()
|
||||||
|
# Any server still pending here hit the global deadline.
|
||||||
|
if pending:
|
||||||
|
elapsed = time.time() - start
|
||||||
|
_logger.warning(
|
||||||
|
f"check global deadline ({deadline}s) reached after "
|
||||||
|
f"{elapsed:.1f}s; {len(pending)} server(s) "
|
||||||
|
f"did not answer in time"
|
||||||
|
)
|
||||||
|
for fut in pending:
|
||||||
|
wid, url = fut_to_target[fut]
|
||||||
|
if wid in results:
|
||||||
|
continue
|
||||||
|
if on_timeout is not None:
|
||||||
|
try:
|
||||||
|
on_timeout(wid, url)
|
||||||
|
except Exception as cb_err:
|
||||||
|
_logger.error(
|
||||||
|
f"check on_timeout callback error: {cb_err}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
except TypeError:
|
||||||
|
pool.shutdown(wait=False)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None):
|
||||||
|
old_willexecutor=old_willexecutor if old_willexecutor is not None else {}
|
||||||
|
willexecutor["url"] = url
|
||||||
|
if status is not None:
|
||||||
|
willexecutor["status"] = status
|
||||||
|
else:
|
||||||
|
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
|
||||||
|
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
|
||||||
|
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address",""))
|
||||||
|
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def download_list(old_willexecutors,welist_server):
|
||||||
|
try:
|
||||||
|
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
|
||||||
|
willexecutors = Willexecutors.send_request(
|
||||||
|
"get",
|
||||||
|
f"{welist_server}data/{chainname}?page=0&limit=100",
|
||||||
|
)
|
||||||
|
# del willexecutors["status"]
|
||||||
|
for w in willexecutors:
|
||||||
|
if w not in ("status", "url"):
|
||||||
|
Willexecutors.initialize_willexecutor(
|
||||||
|
willexecutors[w], w, None, old_willexecutors.get(w,None)
|
||||||
|
)
|
||||||
|
# bal_plugin.WILLEXECUTORS.set(l)
|
||||||
|
# bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True)
|
||||||
|
return willexecutors
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Failed to download willexecutors list: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_willexecutors_list_from_json():
|
||||||
|
try:
|
||||||
|
with open("willexecutors.json") as f:
|
||||||
|
willexecutors = json.load(f)
|
||||||
|
for w in willexecutors:
|
||||||
|
willexecutor = willexecutors[w]
|
||||||
|
Willexecutors.initialize_willexecutor(willexecutor, w, "New", False)
|
||||||
|
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
||||||
|
return willexecutors
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"error opening willexecutors json: {e}")
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_transaction(txid, url, *, timeout=CHECK_TIMEOUT,
|
||||||
|
max_retries=CHECK_MAX_RETRIES,
|
||||||
|
retry_sleep=CHECK_RETRY_SLEEP):
|
||||||
|
_logger.debug(f"{url}:{txid}")
|
||||||
|
try:
|
||||||
|
w = Willexecutors.send_request(
|
||||||
|
"post", url + "/searchtx", data=txid.encode("ascii"),
|
||||||
|
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||||
|
)
|
||||||
|
return w
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"error contacting {url} for checking txs {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_id(willexecutor):
|
||||||
|
return "{}-{}".format(willexecutor.get("url"), willexecutor.get("chain"))
|
||||||
|
|
||||||
|
|
||||||
|
#class WillExecutor:
|
||||||
|
# def __init__(
|
||||||
|
# self,
|
||||||
|
# url,
|
||||||
|
# base_fee,
|
||||||
|
# chain,
|
||||||
|
# info,
|
||||||
|
# version,
|
||||||
|
# status,
|
||||||
|
# is_selected=False,
|
||||||
|
# promo_code="",
|
||||||
|
# ):
|
||||||
|
# self.url = url
|
||||||
|
# self.base_fee = base_fee
|
||||||
|
# self.chain = chain
|
||||||
|
# self.info = info
|
||||||
|
# self.version = version
|
||||||
|
# self.status = status
|
||||||
|
# self.promo_code = promo_code
|
||||||
|
# self.is_selected = is_selected
|
||||||
|
# self.id = self.compute_id()
|
||||||
|
#
|
||||||
|
# def from_dict(d):
|
||||||
|
# return WillExecutor(
|
||||||
|
# url=d.get("url", "http://localhost:8000"),
|
||||||
|
# base_fee=d.get("base_fee", 1000),
|
||||||
|
# chain=d.get("chain", chainname),
|
||||||
|
# info=d.get("info", ""),
|
||||||
|
# version=d.get("version", 0),
|
||||||
|
# status=d.get("status", "Ko"),
|
||||||
|
# is_selected=d.get("is_selected", "False"),
|
||||||
|
# promo_code=d.get("promo_code", ""),
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# def to_dict(self):
|
||||||
|
# return {
|
||||||
|
# "url": self.url,
|
||||||
|
# "base_fee": self.base_fee,
|
||||||
|
# "chain": self.chain,
|
||||||
|
# "info": self.info,
|
||||||
|
# "version": self.version,
|
||||||
|
# "promo_code": self.promo_code,
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# def compute_id(self):
|
||||||
|
# return f"{self.url}-{self.chain}"
|
||||||
Reference in New Issue
Block a user