1130 lines
43 KiB
Python
1130 lines
43 KiB
Python
"""
|
|
bal.cli.controller
|
|
==================
|
|
|
|
Headless replica of the Qt flows (``bal.gui.qt.window.BalWindow``) for the
|
|
command-line front-end.
|
|
|
|
The CLI layer is a daemon, so there is no widget to drive: every operation
|
|
must run to completion synchronously and return a JSON-serializable result
|
|
(or raise ``electrum.util.UserFacingException`` with a human-readable message).
|
|
This controller therefore mirrors the *logic* of the GUI window (its state
|
|
object, the build/check/sign/push flows and the willexecutors CRUD) while
|
|
reusing only ``bal.core`` - it MUST never import PyQt.
|
|
|
|
Errors:
|
|
- Domain exceptions from ``bal.core`` are translated into
|
|
``UserFacingException`` so the JSON-RPC layer can print them cleanly.
|
|
- Network flows (ping/push/check/download) block the calling thread with
|
|
the same timeouts the GUI uses, so the daemon never hangs forever.
|
|
|
|
This module is imported lazily (only when a ``bal_*`` command actually runs),
|
|
so a missing wallet or a network-less daemon can still start Electrum.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import time
|
|
|
|
from electrum import bitcoin, constants
|
|
from electrum.i18n import _
|
|
from electrum.logging import get_logger
|
|
from electrum.transaction import tx_from_any
|
|
from electrum.util import (
|
|
MyEncoder,
|
|
UserFacingException,
|
|
read_json_file,
|
|
write_json_file,
|
|
)
|
|
|
|
from ..core import checkalive
|
|
from ..core import heirs as heirs_mod
|
|
from ..core import will as will_mod
|
|
from ..core.checkalive import (
|
|
CheckAliveError,
|
|
check_alive_expired,
|
|
resolve_date_to_check,
|
|
)
|
|
from ..core.heirs import Heirs, is_op_return_address
|
|
from ..core.plugin_base import BalConfig, BalPlugin
|
|
from ..core.util import Util
|
|
from ..core.will import Will, WillItem
|
|
from ..core.willexecutors import Willexecutors, is_onion_url, is_tor_active
|
|
|
|
_logger = get_logger(__name__)
|
|
|
|
|
|
def _user_facing(e):
|
|
"""Translate a ``bal.core`` domain exception into a user-facing message.
|
|
|
|
Unknown exceptions are passed through unchanged so their real type reaches
|
|
the caller (and, eventually, the Electrum log).
|
|
"""
|
|
if isinstance(e, UserFacingException):
|
|
return e
|
|
if isinstance(e, will_mod.NoHeirsException):
|
|
return UserFacingException(_("There are no valid heirs"))
|
|
if isinstance(e, will_mod.WillExpiredException):
|
|
return UserFacingException(
|
|
_("The will is expired and must be invalidated on-chain and rebuilt")
|
|
)
|
|
if isinstance(e, will_mod.WillPostponedException):
|
|
return UserFacingException(
|
|
_(
|
|
"This inheritance was already signed/sent to will-executors and "
|
|
"you are postponing it. The invalidation transaction must be "
|
|
"signed and broadcast FIRST, then the will prepared again."
|
|
)
|
|
)
|
|
if isinstance(e, will_mod.HeirNotFoundException):
|
|
return UserFacingException(
|
|
_("Found CHANGES to the DATE or the HEIRS, a new WILL must be prepared")
|
|
)
|
|
if isinstance(e, will_mod.TxFeesChangedException):
|
|
return UserFacingException(_("Transaction fees are changed"))
|
|
if isinstance(e, will_mod.WillExecutorNotPresent):
|
|
return UserFacingException(_("Will-Executor not present"))
|
|
if isinstance(e, will_mod.NoWillExecutorNotPresent):
|
|
return UserFacingException(_("No backup transaction or will-executor selected"))
|
|
if isinstance(e, will_mod.AmountException):
|
|
return UserFacingException(
|
|
_(
|
|
"In the inheritance process, the entire wallet will always be "
|
|
"fully emptied. Your settings require an adjustment of the "
|
|
f"amounts: {e}"
|
|
)
|
|
)
|
|
if isinstance(e, heirs_mod.WillExecutorFeeTooHighException):
|
|
return UserFacingException(_(f"Will-executor fee too high: {e}"))
|
|
if isinstance(e, heirs_mod.BalanceTooLowException):
|
|
return UserFacingException(str(e))
|
|
if isinstance(e, heirs_mod.HeirAmountIsDustException):
|
|
return UserFacingException(str(e))
|
|
if isinstance(e, heirs_mod.NotAnAddress):
|
|
return UserFacingException(_(f"not an address, {e}"))
|
|
if isinstance(e, heirs_mod.AmountNotValid):
|
|
return UserFacingException(str(e))
|
|
if isinstance(e, heirs_mod.LocktimeNotValid):
|
|
return UserFacingException(str(e))
|
|
if isinstance(e, checkalive.CheckAliveError):
|
|
return UserFacingException(
|
|
_(
|
|
"CheckAlive is in the past: update it to a date in the future "
|
|
"but less than the locktime"
|
|
)
|
|
)
|
|
return e
|
|
|
|
|
|
class BalController:
|
|
"""Headless per-wallet controller replicating :class:`BalWindow`.
|
|
|
|
One instance wraps one wallet; commands construct it on demand with
|
|
``BalController(plugin, wallet)``. It mirrors the GUI's state object
|
|
(``will_settings``, ``heirs``, ``will``, ``willitems``, ``willexecutors``,
|
|
``date_to_check``) and its flows.
|
|
"""
|
|
|
|
def __init__(self, plugin, wallet):
|
|
self.plugin = plugin
|
|
self.wallet = wallet
|
|
self.heirs = {}
|
|
self.will = {}
|
|
self.willitems = {}
|
|
self.willexecutors = {}
|
|
self.will_settings = {}
|
|
self.date_to_check = None
|
|
self.no_willexecutor = False
|
|
|
|
# The GUI wires ``plugin.get_decimal_point`` from the window; here the
|
|
# daemon reads Electrum's global unit setting (falling back to 8 when
|
|
# the config object does not expose it).
|
|
plugin.get_decimal_point = self._get_decimal_point
|
|
|
|
self._init_settings()
|
|
self._init_heirs()
|
|
self._init_will()
|
|
self._init_willexecutors()
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Init
|
|
# ------------------------------------------------------------------ #
|
|
def _get_decimal_point(self):
|
|
try:
|
|
return self.plugin.config.BTC_AMOUNTS_DECIMAL_POINT
|
|
except AttributeError:
|
|
return 8
|
|
|
|
def _init_settings(self):
|
|
self.will_settings = self.plugin.WILL_SETTINGS.get()
|
|
if not self.will_settings:
|
|
self.will_settings = self.plugin.default_will_settings()
|
|
Util.fix_will_settings_tx_fees(self.will_settings)
|
|
|
|
def _init_heirs(self):
|
|
self.heirs = Heirs._validate(Heirs(self.wallet))
|
|
|
|
def _init_will(self):
|
|
self.will = self.wallet.db.get_dict("will")
|
|
Util.fix_will_tx_fees(self.will)
|
|
self.load_willitems()
|
|
|
|
def _init_willexecutors(self):
|
|
self.willexecutors = Willexecutors.get_willexecutors(
|
|
self.plugin, update=False, task=False
|
|
)
|
|
self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get())
|
|
|
|
def load_willitems(self):
|
|
self.willitems = {}
|
|
for wid, w in self.will.items():
|
|
self.willitems[wid] = WillItem(w, wallet=self.wallet)
|
|
|
|
def save_willitems(self):
|
|
"""Persist the in-memory willitems into the wallet's ``will`` dict.
|
|
|
|
The transaction is stored serialized (exactly like the GUI, which
|
|
avoids deep-copying a live Transaction holding a ``threading.RLock``)
|
|
and every value is proven JSON-serializable before it is written.
|
|
"""
|
|
keys = list(self.will.keys())
|
|
for k in keys:
|
|
del self.will[k]
|
|
for wid, w in self.willitems.items():
|
|
d = w.to_dict()
|
|
d["tx"] = str(d["tx"])
|
|
try:
|
|
json.dumps(d, cls=MyEncoder)
|
|
except Exception as e:
|
|
_logger.error(f"save_willitems: will {wid} is not serializable: {e!r}")
|
|
raise
|
|
self.will[wid] = d
|
|
self.wallet.save_db()
|
|
|
|
def _save_to_history(self):
|
|
"""Persist the will state into the wallet's LOCAL history (best-effort).
|
|
|
|
Mirrors ``BalWindow._save_will_to_history``: only when the
|
|
``SAVE_HISTORY`` setting is enabled, and never raises.
|
|
"""
|
|
try:
|
|
if not bool(self.plugin.SAVE_HISTORY.get()):
|
|
return
|
|
Will.save_valid_transactions_to_history(
|
|
self.willitems, self.wallet, self.plugin.HISTORY_LABEL.get()
|
|
)
|
|
except Exception as e:
|
|
_logger.error(f"save_to_history failed: {e}")
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Serialization helpers
|
|
# ------------------------------------------------------------------ #
|
|
def _willitem_summary(self, wi):
|
|
out = {
|
|
"txid": wi._id,
|
|
"tx": str(wi.tx) if wi.tx is not None else None,
|
|
"locktime": int(wi.tx.locktime) if wi.tx is not None else None,
|
|
"heirs": list((wi.heirs or {}).keys()) if wi.heirs is not None else [],
|
|
"tx_fees": int(wi.tx_fees),
|
|
"description": wi.description,
|
|
"sigs_have": int(getattr(wi, "sigs_have", 0)),
|
|
"sigs_required": int(getattr(wi, "sigs_required", 0)),
|
|
}
|
|
if wi.we:
|
|
out["willexecutor"] = wi.we.get("url")
|
|
out["status"] = {}
|
|
for key, value in wi.STATUS.items():
|
|
out["status"][key] = bool(value[1])
|
|
return out
|
|
|
|
def _will_status_dict(self):
|
|
items = [self._willitem_summary(w) for w in self.willitems.values()]
|
|
counts = {}
|
|
for w in self.willitems.values():
|
|
for key, value in w.STATUS.items():
|
|
counts[key] = counts.get(key, 0) + (1 if value[1] else 0)
|
|
return {
|
|
"count": len(items),
|
|
"items": items,
|
|
"status_counts": counts,
|
|
"date_to_check": self.date_to_check,
|
|
}
|
|
|
|
def _tx_out(self, tx):
|
|
if tx is None:
|
|
return {"txid": None, "tx": None}
|
|
return {"txid": tx.txid(), "tx": str(tx)}
|
|
|
|
def _available_utxos(self):
|
|
return Util.get_available_utxos(
|
|
self.wallet,
|
|
self.plugin.HISTORY_LABEL.get(),
|
|
Will.get_min_locktime(self.willitems, default_value=self.date_to_check),
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Core flows (mirror of BalWindow)
|
|
# ------------------------------------------------------------------ #
|
|
def init_class_variables(self):
|
|
if not self.heirs:
|
|
raise will_mod.NoHeirsException(_("Heirs are not defined"))
|
|
self.date_to_check = resolve_date_to_check(
|
|
self.plugin.is_basic_mode(),
|
|
self.will_settings,
|
|
built_locktime=Will.get_min_locktime(self.willitems),
|
|
)
|
|
self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get())
|
|
self.willexecutors = Willexecutors.get_willexecutors(
|
|
self.plugin, update=True, task=False
|
|
)
|
|
if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check):
|
|
raise CheckAliveError(self.date_to_check)
|
|
self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get())
|
|
|
|
def _init_heirs_to_locktime(self, multiverse=False):
|
|
if multiverse:
|
|
return
|
|
locktime = self.will_settings["locktime"]
|
|
if not isinstance(locktime, (int, float, str)):
|
|
locktime = str(locktime)
|
|
updates = {
|
|
heir: [self.heirs[heir][0], self.heirs[heir][1], locktime]
|
|
for heir in list(self.heirs)
|
|
}
|
|
for heir, value in updates.items():
|
|
self.heirs[heir] = value
|
|
self.wallet.save_db()
|
|
|
|
def check_will(self):
|
|
return Will.is_will_valid(
|
|
self.willitems,
|
|
self.date_to_check,
|
|
self.will_settings["baltx_fees"],
|
|
self._available_utxos(),
|
|
heirs=self.heirs,
|
|
willexecutors=self.willexecutors,
|
|
self_willexecutor=self.no_willexecutor,
|
|
wallet=self.wallet,
|
|
)
|
|
|
|
def build_will(self, ignore_duplicate=True, keep_original=True):
|
|
"""Build (or rebuild) the inheritance transactions.
|
|
|
|
Mirrors ``BalWindow.build_will``; raises ``NoWillExecutorNotPresent``
|
|
when no valid will-executor is selected and the user is not their own
|
|
executor.
|
|
"""
|
|
will = {}
|
|
self.willexecutors = Willexecutors.get_willexecutors(
|
|
self.plugin, update=False, task=False
|
|
)
|
|
if not self.no_willexecutor:
|
|
valid = False
|
|
for _u, w in self.willexecutors.items():
|
|
if Willexecutors.is_selected(w) and Willexecutors.is_valid(
|
|
w,
|
|
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
|
|
dust=self.wallet.dust_threshold(),
|
|
):
|
|
valid = True
|
|
if not valid:
|
|
raise will_mod.NoWillExecutorNotPresent(
|
|
"No Will-Executor or backup transaction selected"
|
|
)
|
|
txs = self.heirs.get_transactions(
|
|
self.plugin,
|
|
self.wallet,
|
|
self.will_settings["baltx_fees"],
|
|
self._available_utxos(),
|
|
self.date_to_check,
|
|
)
|
|
creation_time = time.time()
|
|
if txs:
|
|
for txid in txs:
|
|
tx = {}
|
|
tx["tx"] = txs[txid]
|
|
tx["my_locktime"] = txs[txid].my_locktime
|
|
tx["heirsvalue"] = txs[txid].heirsvalue
|
|
tx["description"] = txs[txid].description
|
|
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
|
tx["status"] = _("New")
|
|
tx["baltx_fees"] = txs[txid].tx_fees
|
|
tx["time"] = creation_time
|
|
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
|
tx["txchildren"] = []
|
|
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
|
Will.update_will(self.willitems, will)
|
|
self.willitems.update(will)
|
|
Will.normalize_will(self.willitems, self.wallet)
|
|
else:
|
|
_logger.info("No transactions was built")
|
|
return {}
|
|
return self.willitems
|
|
|
|
def invalidate_will(self, will=None):
|
|
"""Build the on-chain invalidation transaction (real fee).
|
|
|
|
Returns the raw ``PartialTransaction`` (or ``None`` when there is
|
|
nothing to invalidate); the caller decides how to show/sign it.
|
|
"""
|
|
willitems = will if will is not None else self.willitems
|
|
fee_per_byte = self.will_settings.get("baltx_fees", 1)
|
|
tx = Will.invalidate_will(
|
|
willitems,
|
|
self.wallet,
|
|
fee_per_byte,
|
|
history_label=self.plugin.HISTORY_LABEL.get(),
|
|
will_locktime=Will.get_min_locktime(
|
|
willitems, default_value=self.date_to_check
|
|
),
|
|
)
|
|
if tx is not None:
|
|
try:
|
|
self.wallet.set_label(tx.txid(), "BAL Invalidate transaction")
|
|
except Exception as e:
|
|
_logger.debug(f"invalidate_will set_label failed: {e}")
|
|
return tx
|
|
|
|
def invalidate_will_command(self):
|
|
"""Command-facing wrapper of :meth:`invalidate_will` (JSON-safe)."""
|
|
return self._tx_out(self.invalidate_will())
|
|
|
|
def will_status(self):
|
|
"""Command-facing snapshot of the current will (JSON-safe)."""
|
|
return self._will_status_dict()
|
|
|
|
def will_check(self):
|
|
"""Check the local coherence of the will (heirs, executors, fees).
|
|
|
|
Returns ``{"valid": true}`` or raises a translated domain exception.
|
|
"""
|
|
try:
|
|
self.init_class_variables()
|
|
self.check_will()
|
|
except Exception as e:
|
|
raise _user_facing(e) from e
|
|
return {"valid": True}
|
|
|
|
def prepare_will(self, ignore_duplicate=True, keep_original=True):
|
|
"""Run the full "prepare inheritance" flow and return a status dict.
|
|
|
|
Mirrors ``BalWindow.build_inheritance_transaction``:
|
|
|
|
* ``coherent`` -> the existing will is still valid; nothing rebuilt.
|
|
* ``rebuilt`` -> the will was not coherent and has been rebuilt.
|
|
* ``expired`` / ``postponed`` -> an invalidation transaction must be
|
|
signed and broadcast before a new will can be prepared.
|
|
"""
|
|
if not self.heirs:
|
|
raise UserFacingException(_("Heirs are not defined: add at least one heir first"))
|
|
|
|
self.init_class_variables()
|
|
if self.date_to_check is None:
|
|
raise UserFacingException(_("cannot resolve the check-alive date"))
|
|
date_to_check: float = self.date_to_check
|
|
try:
|
|
Will.check_amounts(
|
|
self.heirs,
|
|
self.willexecutors,
|
|
self._available_utxos(),
|
|
date_to_check,
|
|
self.wallet.dust_threshold(),
|
|
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
|
|
)
|
|
except Exception as e:
|
|
raise _user_facing(e) from e
|
|
|
|
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
|
if locktime < date_to_check:
|
|
raise UserFacingException(_("locktime is lower than threshold"))
|
|
|
|
if not self.no_willexecutor:
|
|
valid = False
|
|
for _k, we in self.willexecutors.items():
|
|
if Willexecutors.is_selected(we) and Willexecutors.is_valid(
|
|
we,
|
|
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
|
|
dust=self.wallet.dust_threshold(),
|
|
):
|
|
valid = True
|
|
if not valid:
|
|
raise UserFacingException(
|
|
_("no backup transaction or willexecutor selected")
|
|
)
|
|
|
|
try:
|
|
self.check_will()
|
|
self.save_willitems()
|
|
return {
|
|
"result": "coherent",
|
|
"message": _("The will is coherent"),
|
|
"will": self._will_status_dict(),
|
|
}
|
|
except will_mod.WillExpiredException:
|
|
inv = self._tx_out(self.invalidate_will())
|
|
return {
|
|
"result": "expired",
|
|
"message": _(
|
|
"The will is expired: sign and broadcast the invalidation "
|
|
"transaction, then prepare again"
|
|
),
|
|
"invalidation_tx": inv,
|
|
"will": self._will_status_dict(),
|
|
}
|
|
except will_mod.WillPostponedException as e:
|
|
_logger.info(f"will postponed: {e}")
|
|
inv = self._tx_out(self.invalidate_will())
|
|
return {
|
|
"result": "postponed",
|
|
"message": _(
|
|
"This inheritance was already signed/sent to will-executors "
|
|
"and you are postponing it. Sign and broadcast the "
|
|
"invalidation transaction now, then prepare again."
|
|
),
|
|
"invalidation_tx": inv,
|
|
"will": self._will_status_dict(),
|
|
}
|
|
except will_mod.NotCompleteWillException as e:
|
|
_logger.info(f"will not coherent ({type(e).__name__}): rebuilding")
|
|
self.build_will(ignore_duplicate, keep_original)
|
|
rebuilt_ok = False
|
|
try:
|
|
self.check_will()
|
|
for wid, _w in self.willitems.items():
|
|
try:
|
|
self.wallet.set_label(wid, "BAL Inheritance transaction")
|
|
except Exception as label_err:
|
|
_logger.debug(f"prepare_will set_label failed: {label_err}")
|
|
rebuilt_ok = True
|
|
except will_mod.WillExpiredException:
|
|
inv = self._tx_out(self.invalidate_will())
|
|
return {
|
|
"result": "rebuilt_expired",
|
|
"message": _(
|
|
"The rebuilt will is expired: invalidate on-chain, "
|
|
"then prepare again"
|
|
),
|
|
"invalidation_tx": inv,
|
|
"will": self._will_status_dict(),
|
|
}
|
|
except will_mod.NotCompleteWillException as e2:
|
|
raise UserFacingException(
|
|
_("Error: {} Please, check your heirs, locktime and threshold!").format(
|
|
str(e2)
|
|
)
|
|
) from e2
|
|
self.save_willitems()
|
|
if rebuilt_ok:
|
|
self._save_to_history()
|
|
return {
|
|
"result": "rebuilt",
|
|
"message": _(
|
|
"The will was rebuilt and needs to be signed and "
|
|
"broadcast again"
|
|
),
|
|
"will": self._will_status_dict(),
|
|
}
|
|
raise UserFacingException(_("will not rebuilt")) from None
|
|
|
|
def sign_transactions(self, password, txids=None):
|
|
"""Sign the valid will transactions (or a subset given by ``txids``).
|
|
|
|
Returns ``{txid: raw_tx}`` for every signed transaction. Raises
|
|
``UserFacingException`` when the wallet is encrypted and no password is
|
|
given.
|
|
"""
|
|
willitems = self.willitems
|
|
if password is None and self.wallet.has_keystore_encryption():
|
|
raise UserFacingException(_("Password required to sign transactions"))
|
|
|
|
if txids is not None:
|
|
targets = [
|
|
t for t in txids if t in willitems and willitems[t].get_status("VALID")
|
|
]
|
|
else:
|
|
targets = Will.only_valid(willitems)
|
|
|
|
txs = {}
|
|
for txid in targets:
|
|
wi = willitems[txid]
|
|
tx = Will.get_tx_from_any(str(wi.tx))
|
|
if wi.get_status("COMPLETE"):
|
|
txs[txid] = tx
|
|
continue
|
|
for txin in tx.inputs():
|
|
prevout = txin.prevout.to_json()
|
|
if prevout[0] in willitems:
|
|
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
|
txin._trusted_value_sats = change.value
|
|
try:
|
|
txin.script_descriptor = change.script_descriptor
|
|
except Exception:
|
|
pass
|
|
txin.is_mine = True
|
|
txin._TxInput__address = change.address
|
|
txin._TxInput__scriptpubkey = change.scriptpubkey
|
|
txin._TxInput__value_sats = change.value
|
|
|
|
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
|
if tx.is_complete():
|
|
wi.set_status("COMPLETE", True)
|
|
try:
|
|
have, required = tx.signature_count()
|
|
wi.sigs_have = int(have)
|
|
wi.sigs_required = int(required)
|
|
except Exception as e:
|
|
_logger.debug(f"signature_count after signing failed: {e}")
|
|
wi.tx = Will.get_tx_from_any(str(tx))
|
|
txs[txid] = tx
|
|
|
|
try:
|
|
Will.check_signatures(willitems, self.wallet)
|
|
except Exception as e:
|
|
_logger.error(f"check_signatures after signing failed: {e}")
|
|
self.save_willitems()
|
|
self._save_to_history()
|
|
return txs
|
|
|
|
def push_transactions_to_willexecutors(self, force=False, txids=None):
|
|
"""Push the valid+signed will transactions to their will-executors.
|
|
|
|
Returns ``{url: broadcast_status}`` and raises ``UserFacingException``
|
|
when no transaction matched the (optional) filter.
|
|
"""
|
|
willitems = self.willitems
|
|
if txids is not None:
|
|
willitems = {t: willitems[t] for t in txids if t in willitems}
|
|
if not willitems:
|
|
raise UserFacingException(_("No transaction matches the given txids"))
|
|
|
|
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
|
if not willexecutors:
|
|
return {}
|
|
|
|
for url in willexecutors:
|
|
willexecutors[url].setdefault("broadcast_status", _("waiting..."))
|
|
|
|
error = {"flag": False}
|
|
already_present = []
|
|
|
|
def on_each(url, willexecutor, ok, exc):
|
|
if isinstance(exc, Willexecutors.AlreadyPresentException):
|
|
already_present.append(url)
|
|
willexecutor["broadcast_status"] = _("checking...")
|
|
elif ok:
|
|
for wid in willexecutor.get("txsids", []):
|
|
willitems[wid].set_status("PUSHED", True)
|
|
willexecutor["broadcast_status"] = _("Success")
|
|
else:
|
|
for wid in willexecutor.get("txsids", []):
|
|
willitems[wid].set_status("PUSH_FAIL", True)
|
|
error["flag"] = True
|
|
willexecutor["broadcast_status"] = _("Failed")
|
|
willexecutor.pop("txs", None)
|
|
|
|
Willexecutors.push_transactions_parallel(willexecutors, on_each=on_each)
|
|
|
|
for url in already_present:
|
|
willexecutor = willexecutors[url]
|
|
for wid in willexecutor.get("txsids", []):
|
|
w = self.willitems[wid]
|
|
try:
|
|
w.set_check_willexecutor(
|
|
Willexecutors.check_transaction(wid, w.we["url"])
|
|
)
|
|
except Exception as e:
|
|
_logger.error(f"check after already-present failed for {wid}: {e}")
|
|
w.set_check_willexecutor(None)
|
|
|
|
self.save_willitems()
|
|
out = {
|
|
url: we.get("broadcast_status", _("unknown"))
|
|
for url, we in willexecutors.items()
|
|
}
|
|
if error["flag"]:
|
|
out["_error"] = True
|
|
return out
|
|
|
|
def check_transactions(self, txids=None):
|
|
"""Ask every will-executor whether it holds our (pushed) transactions.
|
|
|
|
Returns ``{txid: {pushed, checked, check_fail}}``.
|
|
"""
|
|
targets = []
|
|
for wid, w in self.willitems.items():
|
|
if not w.we:
|
|
continue
|
|
if txids is not None and wid not in txids:
|
|
continue
|
|
if Will.needs_server_check(w):
|
|
targets.append((wid, w.we["url"]))
|
|
|
|
def on_each(wid, url, res, exc):
|
|
try:
|
|
self.willitems[wid].set_check_willexecutor(res)
|
|
except Exception as e:
|
|
_logger.error(f"check on_each error for {wid}: {e}")
|
|
|
|
def on_timeout(wid, url):
|
|
try:
|
|
self.willitems[wid].set_check_willexecutor(None)
|
|
except Exception as e:
|
|
_logger.error(f"check on_timeout error for {wid}: {e}")
|
|
|
|
Willexecutors.check_transactions_parallel(
|
|
targets, on_each=on_each, on_timeout=on_timeout
|
|
)
|
|
|
|
self.save_willitems()
|
|
out = {}
|
|
for wid, w in self.willitems.items():
|
|
if w.we:
|
|
out[wid] = {
|
|
"url": w.we.get("url"),
|
|
"pushed": bool(w.get_status("PUSHED")),
|
|
"checked": bool(w.get_status("CHECKED")),
|
|
"check_fail": bool(w.get_status("CHECK_FAIL")),
|
|
}
|
|
return out
|
|
|
|
def export_will(self, path):
|
|
"""Export the whole will to ``path`` (JSON) and flag items EXPORTED."""
|
|
for wid in self.willitems:
|
|
self.willitems[wid].set_status("EXPORTED", True)
|
|
self.will[wid] = self.willitems[wid].to_dict()
|
|
write_json_file(path, self.will)
|
|
self.save_willitems()
|
|
return {"exported": len(self.willitems), "path": path}
|
|
|
|
def _load_will_file(self, path):
|
|
data = read_json_file(path)
|
|
willitems = {}
|
|
for k, v in data.items():
|
|
data[k]["tx"] = tx_from_any(v["tx"])
|
|
willitems[k] = WillItem(data[k], _id=k)
|
|
return willitems
|
|
|
|
def merge_will(self, imported):
|
|
"""Merge imported will items into the live will.
|
|
|
|
Mirrors ``BalWindow.merge_will``: operational statuses are carried over,
|
|
unsigned live transactions are combined/substituted, new transactions
|
|
are added wholesale, then a local validity check recomputes the
|
|
valid/invalidated/replaced statuses.
|
|
"""
|
|
if self.date_to_check is None:
|
|
self.date_to_check = resolve_date_to_check(
|
|
self.plugin.is_basic_mode(), self.will_settings
|
|
)
|
|
for wid, wi in imported.items():
|
|
if wid in self.willitems:
|
|
live = self.willitems[wid]
|
|
was_complete = live.get_status("COMPLETE")
|
|
for status in (
|
|
"COMPLETE",
|
|
"PUSHED",
|
|
"CHECKED",
|
|
"MEMPOOL",
|
|
"CONFIRMED",
|
|
):
|
|
if wi.get_status(status):
|
|
live.set_status(status, True)
|
|
if not was_complete:
|
|
try:
|
|
if live.tx.txid() == wi.tx.txid():
|
|
live.tx.combine_with_other_psbt(wi.tx)
|
|
else:
|
|
live.tx = wi.tx
|
|
except Exception:
|
|
live.tx = wi.tx
|
|
if live.tx.is_complete():
|
|
live.set_status("COMPLETE", True)
|
|
else:
|
|
self.willitems[wid] = wi
|
|
Will.normalize_will(self.willitems, self.wallet)
|
|
self.save_willitems()
|
|
try:
|
|
Will.add_willtree(self.willitems)
|
|
all_utxos = self._available_utxos()
|
|
Will.check_invalidated(
|
|
self.willitems, Will.utxos_strs(all_utxos), self.wallet
|
|
)
|
|
Will.search_rai(
|
|
Will.get_all_inputs(self.willitems, only_valid=True),
|
|
all_utxos,
|
|
self.willitems,
|
|
self.wallet,
|
|
)
|
|
Will.check_signatures(self.willitems, self.wallet)
|
|
except Exception as e:
|
|
_logger.error(f"merge_will validity check failed: {e}")
|
|
self.save_willitems()
|
|
return {"merged": len(imported)}
|
|
|
|
def merge_will_from_file(self, path):
|
|
try:
|
|
willitems = self._load_will_file(path)
|
|
except Exception as e:
|
|
raise UserFacingException(_("Invalid will file: {}").format(e)) from None
|
|
Will.normalize_will(willitems, self.wallet)
|
|
return self.merge_will(willitems)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Heirs CRUD
|
|
# ------------------------------------------------------------------ #
|
|
def _build_heir_entry(self, name, address, amount, locktime):
|
|
heir = [name, address, amount]
|
|
if locktime is not None:
|
|
heir.append(locktime)
|
|
else:
|
|
heir.append(self.will_settings["locktime"])
|
|
if is_op_return_address(address):
|
|
heir[2] = "0"
|
|
return Heirs.validate_heir(heir[0], heir[1:])
|
|
|
|
def heirs_add(self, name, address, amount, locktime=None):
|
|
value = self._build_heir_entry(name, address, amount, locktime)
|
|
self.heirs[name] = value
|
|
self.wallet.save_db()
|
|
return {"name": name, "value": list(value)}
|
|
|
|
def heirs_update(self, name, address=None, amount=None, locktime=None):
|
|
if name not in self.heirs:
|
|
raise UserFacingException(_("Heir not found: {}").format(name))
|
|
current = list(self.heirs[name])
|
|
address = address if address is not None else current[0]
|
|
amount = amount if amount is not None else current[1]
|
|
locktime = locktime if locktime is not None else current[2]
|
|
value = self._build_heir_entry(name, address, amount, locktime)
|
|
self.heirs[name] = value
|
|
self.wallet.save_db()
|
|
return {"name": name, "value": list(value)}
|
|
|
|
def heirs_delete(self, names):
|
|
deleted = []
|
|
for name in names:
|
|
if name in self.heirs:
|
|
self.heirs.pop(name)
|
|
deleted.append(name)
|
|
self.heirs.save()
|
|
self.wallet.save_db()
|
|
return {"deleted": deleted}
|
|
|
|
def heirs_list(self):
|
|
return {k: list(v) for k, v in self.heirs.items()}
|
|
|
|
def heirs_show(self, name):
|
|
if name not in self.heirs:
|
|
raise UserFacingException(_("Heir not found: {}").format(name))
|
|
return {"name": name, "value": list(self.heirs[name])}
|
|
|
|
def heirs_import(self, path):
|
|
self.heirs.import_file(path)
|
|
self.wallet.save_db()
|
|
return {"imported": len(self.heirs)}
|
|
|
|
def heirs_export(self, path):
|
|
self.heirs.export_file(path)
|
|
return {"exported": len(self.heirs), "path": path}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Will-Executors CRUD
|
|
# ------------------------------------------------------------------ #
|
|
def willexecutors_list(self):
|
|
return {url: dict(we) for url, we in self.willexecutors.items()}
|
|
|
|
def willexecutors_show(self, url):
|
|
if url not in self.willexecutors:
|
|
raise UserFacingException(_("Will-Executor not found: {}").format(url))
|
|
return {"url": url, "willexecutor": dict(self.willexecutors[url])}
|
|
|
|
def _validate_executor_address(self, address):
|
|
if address and not bitcoin.is_address(address, net=constants.net):
|
|
raise UserFacingException(
|
|
_("Invalid will-executor address for this network: {}").format(address)
|
|
)
|
|
|
|
def willexecutors_add(self, url, address="", base_fee=0, info=None):
|
|
if not url:
|
|
raise UserFacingException(_("URL is required"))
|
|
if url in self.willexecutors:
|
|
raise UserFacingException(_("Will-Executor already present: {}").format(url))
|
|
self._validate_executor_address(address)
|
|
info = (info or "").strip() or "New Will Executor"
|
|
self.willexecutors[url] = {
|
|
"info": info,
|
|
"base_fee": int(base_fee),
|
|
"address": address,
|
|
"selected": False,
|
|
"status": "-1",
|
|
"promo_code": None,
|
|
}
|
|
Willexecutors.save(self.plugin, self.willexecutors)
|
|
return self.willexecutors_show(url)
|
|
|
|
def willexecutors_update(self, url, address=None, base_fee=None, info=None,
|
|
rename_to=None):
|
|
if url not in self.willexecutors:
|
|
raise UserFacingException(_("Will-Executor not found: {}").format(url))
|
|
we = self.willexecutors[url]
|
|
if address is not None:
|
|
self._validate_executor_address(address)
|
|
we["address"] = address
|
|
if base_fee is not None:
|
|
we["base_fee"] = int(base_fee)
|
|
if info is not None:
|
|
we["info"] = info.strip() or "New Will Executor"
|
|
if rename_to and rename_to != url:
|
|
if rename_to in self.willexecutors:
|
|
raise UserFacingException(
|
|
_("Will-Executor already present: {}").format(rename_to)
|
|
)
|
|
self.willexecutors[rename_to] = we
|
|
del self.willexecutors[url]
|
|
url = rename_to
|
|
Willexecutors.save(self.plugin, self.willexecutors)
|
|
return self.willexecutors_show(url)
|
|
|
|
def willexecutors_delete(self, urls):
|
|
deleted = []
|
|
for url in urls:
|
|
if url in self.willexecutors:
|
|
del self.willexecutors[url]
|
|
deleted.append(url)
|
|
Willexecutors.save(self.plugin, self.willexecutors)
|
|
return {"deleted": deleted}
|
|
|
|
def willexecutors_select(self, urls=None, select=True):
|
|
"""Select/deselect one, many or all will-executors."""
|
|
selected = {}
|
|
targets = urls if urls is not None else list(self.willexecutors)
|
|
for url in targets:
|
|
if url not in self.willexecutors:
|
|
continue
|
|
self.willexecutors[url]["selected"] = bool(select)
|
|
selected[url] = bool(select)
|
|
Willexecutors.save(self.plugin, self.willexecutors)
|
|
return selected
|
|
|
|
def willexecutors_ping(self, urls=None):
|
|
"""Ping the (selected) will-executor servers and refresh their info.
|
|
|
|
Returns ``{url: {"status": int, "ok": bool}}``.
|
|
"""
|
|
targets = {}
|
|
if urls is not None:
|
|
for url in urls:
|
|
if url not in self.willexecutors:
|
|
raise UserFacingException(
|
|
_("Will-Executor not found: {}").format(url)
|
|
)
|
|
targets[url] = self.willexecutors[url]
|
|
else:
|
|
targets = {
|
|
url: we
|
|
for url, we in self.willexecutors.items()
|
|
if Willexecutors.is_selected(we)
|
|
}
|
|
if not targets:
|
|
raise UserFacingException(_("No will-executor is selected"))
|
|
results = {}
|
|
|
|
def on_each(url, we, ok):
|
|
results[url] = {"status": we.get("status"), "ok": bool(ok)}
|
|
|
|
Willexecutors.ping_servers_parallel(targets, on_each=on_each)
|
|
Willexecutors.save(self.plugin, self.willexecutors)
|
|
return results
|
|
|
|
def _fetch_will_executors_list(self):
|
|
"""Download the will-executor list from the welist server.
|
|
|
|
Mirrors ``BalWindow.fetch_will_executors_list`` (welist URL selection,
|
|
Tor gating for ``.onion`` servers, per-entry validation). Returns the
|
|
downloaded dict, ``{}`` on failure.
|
|
"""
|
|
chainname = BalPlugin.chainname
|
|
basic = self.plugin.is_basic_mode()
|
|
if basic:
|
|
base = self.plugin.WELIST_SERVER.default
|
|
else:
|
|
base = self.plugin.WELIST_SERVER.get()
|
|
base = base if base.endswith("/") else base + "/"
|
|
url = f"{base}data/{chainname}?page=0&limit=100"
|
|
|
|
result = {}
|
|
last_error = None
|
|
try:
|
|
resp = Willexecutors.send_request(
|
|
"get", url, timeout=10, max_retries=1, retry_sleep=1
|
|
)
|
|
if not isinstance(resp, dict):
|
|
last_error = "invalid response format"
|
|
_logger.warning(
|
|
f"fetch_will_executors_list: {url} -> unexpected response "
|
|
f"type {type(resp).__name__}, ignoring"
|
|
)
|
|
else:
|
|
result = resp
|
|
tor_on = is_tor_active()
|
|
for w in list(result.keys()):
|
|
if w in ("status", "url"):
|
|
continue
|
|
if not isinstance(result.get(w), dict):
|
|
del result[w]
|
|
continue
|
|
if not tor_on and is_onion_url(w):
|
|
del result[w]
|
|
continue
|
|
Willexecutors.initialize_willexecutor(
|
|
result[w], w, None, self.willexecutors.get(w, None)
|
|
)
|
|
except Exception as e:
|
|
last_error = str(e)
|
|
_logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}")
|
|
|
|
if not result and not basic:
|
|
raise UserFacingException(
|
|
_("Could not reach the configured welist server.\nServer: {}\nError: {}").format(
|
|
url, last_error or "empty response"
|
|
)
|
|
)
|
|
return result
|
|
|
|
def willexecutors_download(self):
|
|
"""Download the will-executor list and merge it into the local one."""
|
|
result = self._fetch_will_executors_list()
|
|
if result:
|
|
self.willexecutors.update(result)
|
|
Willexecutors.save(self.plugin, self.willexecutors)
|
|
return {"downloaded": len(result), "total": len(self.willexecutors)}
|
|
|
|
def willexecutors_import(self, path):
|
|
data = read_json_file(path)
|
|
if not isinstance(data, dict):
|
|
raise UserFacingException(_("Invalid will-executors file"))
|
|
for url, we in data.items():
|
|
if not isinstance(we, dict):
|
|
raise UserFacingException(
|
|
_("Invalid entry {} in will-executors file").format(url)
|
|
)
|
|
if url not in self.willexecutors:
|
|
we = dict(we)
|
|
we.setdefault("selected", False)
|
|
we.setdefault("status", "New")
|
|
we.setdefault("promo_code", None)
|
|
self.willexecutors[url] = we
|
|
Willexecutors.save(self.plugin, self.willexecutors)
|
|
return {"imported": len(data), "total": len(self.willexecutors)}
|
|
|
|
def willexecutors_export(self, path):
|
|
write_json_file(path, self.willexecutors)
|
|
return {"exported": len(self.willexecutors), "path": path}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Settings
|
|
# ------------------------------------------------------------------ #
|
|
def _configs(self):
|
|
out = {}
|
|
for attr in dir(self.plugin):
|
|
if not attr.isupper():
|
|
continue
|
|
value = getattr(self.plugin, attr, None)
|
|
if isinstance(value, BalConfig):
|
|
out[attr] = value
|
|
return out
|
|
|
|
def _config_key(self, key):
|
|
configs = self._configs()
|
|
aliases = {}
|
|
for attr, cfg in configs.items():
|
|
aliases[attr.upper().replace("-", "_").replace(".", "_")] = (attr, cfg)
|
|
aliases[cfg.name.upper().replace("-", "_").replace(".", "_")] = (attr, cfg)
|
|
normalized = key.upper().replace("-", "_").replace(".", "_")
|
|
if normalized not in aliases:
|
|
raise UserFacingException(
|
|
_("Unknown BAL setting: {} (use bal_settings_list)").format(key)
|
|
)
|
|
return aliases[normalized]
|
|
|
|
def settings_list(self):
|
|
out = {}
|
|
for attr, cfg in self._configs().items():
|
|
out[attr] = {
|
|
"name": cfg.name,
|
|
"default": cfg.default,
|
|
"value": cfg.get(),
|
|
}
|
|
return out
|
|
|
|
def settings_get(self, key):
|
|
attr, cfg = self._config_key(key)
|
|
return {
|
|
"key": attr,
|
|
"name": cfg.name,
|
|
"default": cfg.default,
|
|
"value": cfg.get(),
|
|
}
|
|
|
|
def settings_reset(self, key):
|
|
attr, cfg = self._config_key(key)
|
|
cfg.set(cfg.default)
|
|
return {
|
|
"key": attr,
|
|
"name": cfg.name,
|
|
"default": cfg.default,
|
|
"value": cfg.get(),
|
|
}
|
|
|
|
def settings_set(self, key, value):
|
|
attr, cfg = self._config_key(key)
|
|
coerced = self._coerce_config_value(cfg, value)
|
|
cfg.set(coerced)
|
|
return {
|
|
"key": attr,
|
|
"name": cfg.name,
|
|
"default": cfg.default,
|
|
"value": cfg.get(),
|
|
}
|
|
|
|
def _coerce_config_value(self, cfg, raw):
|
|
if isinstance(cfg.default, bool):
|
|
if isinstance(raw, str):
|
|
low = raw.strip().lower()
|
|
if low in ("true", "1", "yes", "on"):
|
|
return True
|
|
if low in ("false", "0", "no", "off"):
|
|
return False
|
|
raise UserFacingException(
|
|
_("Invalid boolean for {}: {}").format(cfg.name, raw)
|
|
)
|
|
return bool(raw)
|
|
if isinstance(cfg.default, int):
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
raise UserFacingException(
|
|
_("Expected an integer for {}: {}").format(cfg.name, raw)
|
|
) from None
|
|
if isinstance(cfg.default, dict):
|
|
try:
|
|
value = json.loads(raw) if isinstance(raw, str) else raw
|
|
except (json.JSONDecodeError, TypeError):
|
|
raise UserFacingException(
|
|
_("Expected a JSON object for {}").format(cfg.name)
|
|
) from None
|
|
if not isinstance(value, dict):
|
|
raise UserFacingException(
|
|
_("Expected a JSON object for {}").format(cfg.name)
|
|
)
|
|
return value
|
|
return str(raw)
|
|
|
|
|
|
def get_decimal_point(plugin):
|
|
"""Standalone helper returning Electrum's configured BTC decimal point."""
|
|
try:
|
|
return plugin.config.BTC_AMOUNTS_DECIMAL_POINT
|
|
except AttributeError:
|
|
return 8
|