2 Commits

Author SHA1 Message Date
30a5720ceb BalWindow/plugin: history persistence, will import/merge, sig tracking, local-spender fixes 2026-08-01 17:21:36 -04:00
08394f4868 lint: ruff cleanup pass across bal/ and tests/
- Sort imports and fix pyproject ruff config (per-file ignores for
  intentional Qt/core exceptions)
- Mark Heirs.validate_* helpers as @staticmethod
- Clean up dead code, rename shadowing vars, use raise ... from
- Add AGENTS.md with env/lint/test/release guidance
2026-07-31 16:03:17 -04:00
54 changed files with 3632 additions and 444 deletions

76
AGENTS.md Normal file
View File

@@ -0,0 +1,76 @@
# AGENTS.md
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
## Environments (critical)
Two separate venvs; using the wrong one is the #1 mistake.
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
`source /home/steal/devel/bal/electrum/env/bin/activate`
This is an editable install of the Electrum 4.8.0 checkout at
`/home/steal/devel/bal/electrum`. Use it for anything that imports
`electrum`, runs GUI code, or runs tests.
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
import `electrum` or `PyQt6`. Do NOT use it to run tests.
The plugin's `bal/` directory is symlinked into
`electrum/electrum/plugins/bal` (internal-plugin install used during dev).
## Test & verify
Tests are **standalone scripts**, not pytest. Each `tests/test_*.py` file runs
its `test_*` functions from `if __name__ == "__main__"`. Run a file directly:
```bash
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_core_heirs.py # core, no Qt needed
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
```
- Most core tests run offline (no wallet/network). Some files
(`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`)
exercise will-executor/network flows and need the live servers — don't rely on
them for quick verification.
- `tests/smoke_test.py` proves clean import under real Electrum:
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
- `tests/external_zip_test.py` loads the built zip the way Electrum's plugin
dialog does (`electrum_external_plugins.bal`); run it after `build_zip.py`.
## Lint / typecheck
- **Ruff is NOT clean** (hundreds of pre-existing errors in `bal/` and
`tests/`). Do not run `--fix` wholesale and do not try to silence everything;
just avoid adding new violations. Config: `pyproject.toml` (line-length 88,
E501 ignored).
- Lint via the repo venv: `/home/steal/devel/bal/bal-electrum-plugin/venv/bin/ruff`
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
(`extraPaths: ["../electrum"]`). Pyright reports many false positives on
dynamically-attached attrs (e.g. `self.window`, `BalPlugin.*`); don't chase
them.
## Architecture
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
`plugin_base.py`, `util.py`). Must never import Qt.
- `bal/gui/qt/` = PyQt6 layer. `window.py` is the per-wallet controller,
`plugin.py` is the Electrum `@hooks` entry, `qt.py` is a zipimport shim.
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
`make-release.sh`).
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
registration API differs between them (`json_db.register_dict` vs
`stored_dict.register_name`).
## Build / release
```bash
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256)
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
```
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
version there, never invent a new source of truth.
- Remote is Gitea (`origin` = bitcoin-after.life). `.env` holds a Gitea token
(gitignored, never commit it).

View File

@@ -102,7 +102,7 @@ def validate_op_return_hex(data_hex: str) -> None:
try:
data = bytes.fromhex(data_hex)
except ValueError:
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}")
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}") from None
if len(data) > 80:
raise NotAnAddress(
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
@@ -205,7 +205,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
change = get_change_output(wallet, in_amount, out_amount, fee)
if change:
outputs.append(change)
for i in range(0, 100):
for _ in range(0, 100):
random.shuffle(outputs)
#op_return_text = "Hello Bal!"
@@ -281,7 +281,7 @@ def invalidate_inheritance_transactions(wallet):
del dtxs[txid]
utxos = {}
for txid, tx in dtxs.items():
for _, tx in dtxs.items():
get_utxos_from_inputs(tx.inputs(), tx, utxos)
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
@@ -727,7 +727,7 @@ class Heirs(dict, Logger):
)
break
except Exception:
raise e
raise
total_fees = 0
total_fees_real = 0
total_in = 0
@@ -853,6 +853,7 @@ class Heirs(dict, Logger):
except AttributeError:
return None
@staticmethod
def validate_address(address):
if is_op_return_address(address):
data_hex = address[len(OP_RETURN_PREFIX):]
@@ -862,24 +863,27 @@ class Heirs(dict, Logger):
raise NotAnAddress(f"not an address,{address}")
return address
@staticmethod
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}")
raise AmountNotValid(f"amount not properly formatted, {e}") from e
return amount
@staticmethod
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}")
raise LocktimeNotValid(f"locktime string not properly formatted, {e}") from e
return locktime
@staticmethod
def validate_heir(k, v, timestamp_to_check=False):
address = Heirs.validate_address(v[HEIR_ADDRESS])
if is_op_return_address(v[HEIR_ADDRESS]):
@@ -889,6 +893,7 @@ class Heirs(dict, Logger):
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
return (address, amount, locktime)
@staticmethod
def _validate(data, timestamp_to_check=False):
for k, v in list(data.items()):

View File

@@ -228,6 +228,21 @@ class BalPlugin(BasePlugin):
self.PREVIEW = BalConfig(config, "bal_preview", True)
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
# SAVE_HISTORY (history persistence): when enabled, the valid will
# transactions are saved into the wallet's LOCAL history (the History
# tab) after every check, each with a configurable label. Default ON.
self.SAVE_HISTORY = BalConfig(config, "bal_save_history", True)
# HISTORY_LABEL: label text applied to the will transactions saved into
# the wallet's local history. May contain the "{willexecutor}" token,
# which is replaced with the will-executor URL of each will item at
# save time.
self.HISTORY_LABEL = BalConfig(
config,
"bal_history_label",
"BitcoinAfterLife inheritance transaction - {willexecutor}",
)
# AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
# querying the will-executor servers, automatically sign the will
# transactions and broadcast them to their will-executors, without the
@@ -477,14 +492,14 @@ class BalTimestamp:
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
int32_max = 2 ** 31 - 1
try:
return datetime.fromtimestamp(ts)
except (OSError, OverflowError, ValueError):
try:
return datetime.fromtimestamp(min(int(ts), INT32_MAX))
return datetime.fromtimestamp(min(int(ts), int32_max))
except (OSError, OverflowError, ValueError):
return datetime.fromtimestamp(INT32_MAX)
return datetime.fromtimestamp(int32_max)
def to_date(self, from_date=None, reverse=False):
"""Resolve to a ``datetime``.

View File

@@ -20,6 +20,7 @@ original implementation.
import bisect
from datetime import datetime, timedelta
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.transaction import PartialTxOutput
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
@@ -400,14 +401,14 @@ class Util:
def get_lowest_valid_tx(available_utxos, will):
"""Placeholder kept from the original code (sorts the will by locktime)."""
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
for txid, willitem in will.items():
for _txid, _willitem in will.items():
pass
@staticmethod
def get_locktimes(will):
"""Return the distinct locktimes used by the transactions in ``will``."""
locktimes = {}
for txid, willitem in will.items():
for _, willitem in will.items():
locktimes[willitem["tx"].locktime] = True
return locktimes.keys()
@@ -446,7 +447,7 @@ class Util:
def get_will_spent_utxos(will):
"""Collect every input spent by any transaction in ``will``."""
utxos = []
for txid, willitem in will.items():
for _, willitem in will.items():
utxos += willitem["tx"].inputs()
return utxos
@@ -493,6 +494,106 @@ class Util:
return True
return False
@staticmethod
def get_available_utxos(wallet, history_label, will_locktime=None):
"""Return the wallet's UTXOs as seen by the plugin's flows.
``wallet.get_utxos()`` drops any output that a wallet-LOCAL transaction
marks as spent. The plugin itself creates such local spenders when it
saves an incomplete will transaction into the local history; a *later*
will transaction stored there (a replacement/future will with a locktime
strictly after ``will_locktime``) must not hide the coins from the will
being checked or rebuilt. This view therefore restores those coins.
A local spender is ignored (the coin is kept available) only when ALL of
these hold:
* it is a wallet-local or future transaction (not broadcast),
* its wallet label matches the BAL history label template (after the
"{willexecutor}" substitution),
* the stored spender's locktime is strictly LATER than ``will_locktime``.
Real (broadcast/confirmed) spenders are never ignored. With a falsy
``will_locktime`` this returns ``wallet.get_utxos()`` unchanged.
Args:
wallet: The Electrum wallet object.
history_label: The BAL history label template (may contain
"{willexecutor}").
will_locktime: Reference locktime of the will being operated on.
"""
if not wallet or not will_locktime:
return list(wallet.get_utxos()) if wallet else []
adb = getattr(wallet, "adb", None)
if adb is None or not hasattr(adb, "get_addr_outputs"):
return list(wallet.get_utxos())
addresses = (
wallet.get_addresses() if hasattr(wallet, "get_addresses") else []
)
utxos = []
for addr in addresses:
try:
outputs = adb.get_addr_outputs(addr)
except Exception:
continue
for utxo in outputs.values():
if utxo.spent_height is None:
utxos.append(utxo)
continue
spender = getattr(utxo, "spent_txid", None)
if spender and Util._is_ignorable_local_spender(
wallet, spender, history_label, will_locktime
):
utxos.append(utxo)
return utxos
@staticmethod
def _is_ignorable_local_spender(wallet, spender, history_label, will_locktime):
"""True when the local ``spender`` tx is a later BAL history will tx.
See ``get_available_utxos`` for the exact conditions. Defensive: any
lookup failure makes this return False, so a spender is never ignored
on uncertain data.
"""
adb = wallet.adb
try:
height = int(adb.get_tx_height(spender).height())
except Exception:
return False
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return False
try:
label = wallet.get_label_for_txid(spender)
except Exception:
label = None
if not label or not Util._label_matches_history(label, history_label):
return False
try:
stored = adb.db.get_transaction(spender)
except Exception:
return False
if stored is None:
return False
try:
return int(stored.locktime) > int(will_locktime)
except Exception:
return False
@staticmethod
def _label_matches_history(label, history_label):
"""True when ``label`` is the ``history_label`` template with the
"{willexecutor}" token substituted by some (possibly empty) executor URL.
"""
token = "{willexecutor}"
if token in history_label:
prefix, suffix = history_label.split(token, 1)
return (
label.startswith(prefix)
and label.endswith(suffix)
and len(label) >= len(prefix) + len(suffix)
)
return label == history_label
@staticmethod
def cmp_output(outputa, outputb):
"""Two outputs are equal when both address and value match."""

View File

@@ -40,12 +40,13 @@ from electrum.transaction import (
tx_from_any,
)
from electrum.util import (
UnrelatedTransactionException,
bfh,
)
from .heirs import WillExecutorFeeTooHighException
from .util import Util
from .willexecutors import Willexecutors
from .heirs import WillExecutorFeeTooHighException
MIN_LOCKTIME = 1
MIN_BLOCK = 1
@@ -220,13 +221,8 @@ class Will:
if ow.we["url"] == nw.we["url"]:
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
return anticipate
else:
if int(ow.tx_fees) != int(nw.tx_fees):
return anticipate
else:
ow.tx.locktime
else:
ow.tx.locktime
elif int(ow.tx_fees) != int(nw.tx_fees):
return anticipate
else:
if nw.we == ow.we:
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
@@ -456,11 +452,15 @@ class Will:
return out
@staticmethod
def invalidate_will(will, wallet, fees_per_byte):
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None):
print("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid)
utxos = wallet.get_utxos()
if history_label is not None and will_locktime is not None:
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
else:
utxos = wallet.get_utxos()
filtered_inputs = []
prevout_to_spend = []
current_height = Util.get_current_height(wallet.network)
@@ -512,7 +512,7 @@ class Will:
@staticmethod
def is_new(will):
for wid, w in will.items():
for _wid, w in will.items():
if w.get_status("VALID") and not w.get_status("COMPLETE"):
return True
@@ -538,10 +538,26 @@ class Will:
wi.set_status("INVALIDATED", True)
else:
if wallet.db.get_transaction(wi._id):
wi.set_status("CONFIRMED", True)
else:
# The funding outpoint is not part of the will tree:
# decide from whether a broadcast transaction really
# spends it (a wallet-local history copy of the same
# will tx must neither turn the item CONFIRMED nor
# INVALIDATED - it is just a persistence artifact).
stored = None
if wallet and getattr(wallet, "db", None):
try:
stored = wallet.db.get_transaction(wi._id)
except Exception:
stored = None
spender_height = Will._funding_spender_height(wallet, inp)
if spender_height is None:
if stored:
continue
wi.set_status("INVALIDATED", True)
elif spender_height == 0:
wi.set_status("MEMPOOL", True)
else:
wi.set_status("CONFIRMED", True)
for child in wi.search(all_inputs):
if child.tx.locktime < wi.tx.locktime:
@@ -579,14 +595,22 @@ class Will:
for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp)
if inp_str not in utxos_list:
if wallet:
height = Will.check_tx_height(w.tx, wallet)
if height < 0:
if not wallet or not getattr(wallet, "adb", None):
continue
height = Will.check_tx_height(w.tx, wallet)
if height < 0:
# The will tx itself is not on-chain. A missing
# funding UTXO is only a real problem when a
# broadcast transaction actually spends it; a
# wallet-local (history) copy of the same will tx
# marks the funding spent locally and must not
# invalidate the will.
if Will._funding_really_spent(wallet, inp_str):
Will.set_invalidate(wid, willtree)
elif height == 0:
w.set_status("MEMPOOL", True)
else:
w.set_status("CONFIRMED", True)
elif height == 0:
w.set_status("MEMPOOL", True)
else:
w.set_status("CONFIRMED", True)
# def reflect_to_children(treeitem):
# if not treeitem.get_status("VALID"):
@@ -628,6 +652,83 @@ class Will:
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
)
@staticmethod
def _funding_really_spent(wallet, inp_str):
"""True when a broadcast transaction really spends the ``txid:n`` outpoint.
``wallet.adb.get_spender`` discards wallet-local spenders (the stored
will tx from the local history) and future transactions, so this is True
only when the funding was consumed by a real on-chain/mempool tx.
"""
if not wallet or not getattr(wallet, "adb", None):
return False
try:
return wallet.adb.get_spender(inp_str) is not None
except Exception as e:
_logger.error(f"get_spender failed for {inp_str}: {e}")
return False
@staticmethod
def _funding_spender_height(wallet, inp_str):
"""Mined height of the broadcast tx spending ``inp_str``, or None.
Returns ``None`` when no broadcast transaction spends the outpoint (a
wallet-local history spender or a future tx are ignored by
``adb.get_spender``). The height is 0 for a mempool spender and positive
for a confirmed one.
"""
if not wallet or not getattr(wallet, "adb", None):
return None
try:
spender = wallet.adb.get_spender(inp_str)
except Exception as e:
_logger.error(f"get_spender failed for {inp_str}: {e}")
return None
if spender is None:
return None
try:
return int(wallet.adb.get_tx_height(spender).height())
except Exception as e:
_logger.error(f"get_tx_height failed for {spender}: {e}")
return 0
@staticmethod
def _absorb_history_signatures(will, wallet):
"""Merge signatures from the wallet's stored local copy of each will tx.
An incomplete will transaction saved into the local history (see
``save_valid_transactions_to_history``) may later accumulate signatures
(e.g. after a manual merge from a more complete copy). The in-memory
will item would otherwise miss those signatures on the next check. For
every item whose stored wallet copy is the same partial transaction the
signatures are merged into the in-memory one and, if it becomes fully
signed, the item is marked COMPLETE.
This method must never raise: history absorption is a convenience on top
of the will check, so any failure is logged and ignored.
"""
if not wallet or not getattr(wallet, "db", None):
return
for wi in will.values():
try:
if (
wi.tx is None
or not isinstance(wi.tx, PartialTransaction)
or wi.tx.is_complete()
):
continue
stored = wallet.db.get_transaction(wi._id)
if (
not isinstance(stored, Transaction)
or stored.txid() != wi.tx.txid()
):
continue
wi.tx.combine_with_other_psbt(stored)
if wi.tx.is_complete():
wi.set_status("COMPLETE", True)
except Exception as e:
_logger.error(f"absorb history signatures failed for item {wi._id}: {e}")
@staticmethod
def check_will(will, all_utxos, wallet, timestamp_to_check):
"""Validate a will against the current wallet state.
@@ -643,6 +744,7 @@ class Will:
timestamp_to_check: The reference UNIX timestamp (usually "now")
used to decide whether any transaction has expired.
"""
Will._absorb_history_signatures(will, wallet)
Will.add_willtree(will)
utxos_list = Will.utxos_strs(all_utxos)
@@ -656,6 +758,186 @@ class Will:
Will.search_rai(all_inputs, all_utxos, will, wallet)
Will.check_signatures(will, wallet)
@staticmethod
def save_valid_transactions_to_history(will, wallet, history_label):
"""Keep the wallet's LOCAL history in sync with the current will state.
Called after the will has been built/signed/checked (see the
SAVE_HISTORY / HISTORY_LABEL settings). A will transaction belongs in the
local history while it is still "New" (not yet fully signed - i.e. an
incomplete partial transaction), and must be removed once it becomes
"Complete" (fully signed), because at that point it is ready to be
broadcast and will appear in the history on its own.
For every will item that is valid and whose transaction has a txid it:
1. decodes the label template, replacing "{willexecutor}" with the
will-executor URL of the item,
2. if the transaction is NOT complete, stores it via
``wallet.adb.add_transaction`` (merging signatures when an
already-stored partial transaction is upgraded by a more complete
one) and tags it with the decoded label,
3. if the transaction IS complete, does not store it: its matching
local-history entry is removed by the cleanup below.
Finally it deletes every wallet-local transaction whose label exactly
matches the decoded label of a current valid item but that is no longer
among the just-saved transactions, so fully-signed, rebuilt or replaced
wills do not pile up stale entries.
This method must never raise: history persistence is a convenience on
top of the will check, so any failure is logged and ignored.
Args:
will: The will dictionary (WillItem entries keyed by txid).
wallet: The Electrum wallet object (may be falsy for offline
checks, in which case this is a no-op).
history_label: The label template to apply (may contain
"{willexecutor}").
"""
if not wallet or not getattr(wallet, "adb", None):
return
saved_txids = []
try:
current_labels = {
history_label.replace(
"{willexecutor}", (wi.we or {}).get("url", "")
)
for wi in will.values()
if wi.get_status("VALID")
and wi.tx is not None
and wi.tx.txid() is not None
}
for wi in will.values():
if not wi.get_status("VALID"):
continue
if wi.tx is None or wi.tx.txid() is None:
continue
# Fully-signed (complete) transactions must NOT be saved: they
# are removed from the local history so the list does not show a
# placeholder for a transaction that will appear on its own once
# broadcast/confirmed. Only the not-yet-complete "New" items are
# stored. Note that fully-segwit partial txs have a txid even
# when incomplete, so the txid() check alone is not enough.
if wi.tx.is_complete():
continue
try:
txid = wi.tx.txid()
label = history_label.replace(
"{willexecutor}", (wi.we or {}).get("url", "")
)
Will._add_transaction_to_history(wallet, wi.tx, txid)
try:
wallet.set_label(txid, label)
except Exception as e:
_logger.error(f"set_label failed for {txid}: {e}")
saved_txids.append(txid)
except Exception as e:
_logger.error(f"save to history failed for item {wi._id}: {e}")
# Delete stale wallet-local txs whose label matches a current valid
# item but that are no longer among the saved ones. This removes
# entries for fully-signed (complete) items and for rebuilt/replaced
# wills with the same executor.
for txid, label in Will._wallet_labels(wallet):
if txid in saved_txids:
continue
if label not in current_labels:
continue
try:
wallet.adb.remove_transaction(txid)
try:
wallet.set_label(txid, None)
except Exception:
pass
except Exception as e:
_logger.error(f"remove from history failed for {txid}: {e}")
try:
wallet.save_db()
except Exception as e:
_logger.error(f"save_db failed after history update: {e}")
except Exception as e:
_logger.error(f"save_valid_transactions_to_history failed: {e}")
@staticmethod
def _add_transaction_to_history(wallet, tx, txid):
"""Store *tx* into the wallet's local history via ``adb``.
If a partial transaction with the same txid is already stored and *tx*
carries additional signatures, the signatures are merged into the stored
one before saving. ``allow_unrelated`` is retried as a fallback so that
self-created txs (which are not yet part of the wallet's UTXO set) are
still accepted.
"""
adb = wallet.adb
existing = None
try:
existing = wallet.db.get_transaction(txid)
except Exception:
existing = None
try:
if (
isinstance(existing, PartialTransaction)
and not existing.is_complete()
and isinstance(tx, PartialTransaction)
):
existing.combine_with_other_psbt(tx)
adb.add_transaction(existing)
else:
try:
adb.add_transaction(tx)
except UnrelatedTransactionException:
adb.add_transaction(tx, allow_unrelated=True)
except Exception as e:
raise RuntimeError(f"add_transaction failed for {txid}: {e}") from e
@staticmethod
def _wallet_labels(wallet):
"""Return the wallet's ``(txid, label)`` pairs in a defensive way."""
try:
get_all_labels = wallet.get_all_labels
except AttributeError:
return []
try:
return list(get_all_labels().items())
except Exception as e:
_logger.error(f"get_all_labels failed: {e}")
return []
@staticmethod
def check_signatures(will, wallet=None):
"""Refresh the per-item signature counts and the PARTIALLY_SIGNED status.
The signature counts are derived from the transaction itself via
Electrum's ``signature_count()``, which needs a script descriptor on
each input (attached from the wallet when available). Items that already
carry their own descriptors (e.g. imported/merged partial transactions)
are counted even without a wallet.
An item with at least one signature present but fewer than required is
marked PARTIALLY_SIGNED. Items that are already signed (COMPLETE) or
whose transaction is complete always clear the flag.
"""
for wi in will.values():
try:
if wi.get_status("COMPLETE") or wi.tx is None or wi.tx.is_complete():
wi.set_status("PARTIALLY_SIGNED", False)
continue
if wallet:
wi.tx.add_info_from_wallet(wallet)
if not hasattr(wi.tx, "signature_count"):
continue
have, required = wi.tx.signature_count()
wi.sigs_have = int(have)
wi.sigs_required = int(required)
if required > 1 and 0 < have < required:
wi.set_status("PARTIALLY_SIGNED", True)
else:
wi.set_status("PARTIALLY_SIGNED", False)
except Exception as e:
_logger.error(f"check_signatures failed for item {wi._id}: {e}")
@staticmethod
def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@@ -784,7 +1066,7 @@ class Will:
timestamp_to_check: Reference UNIX timestamp (usually "now").
"""
_logger.info("check if some transaction is expired")
for prevout_str, wid in all_inputs_min_locktime.items():
for _inputs, wid in all_inputs_min_locktime.items():
for w in wid:
if w[1].get_status("VALID"):
locktime = int(wid[0][1].tx.locktime)
@@ -981,6 +1263,7 @@ class WillItem(Logger):
"MEMPOOL": ["Mempool", False],
"PUSH_FAIL": ["Push failed", False],
"PUSHED": ["Pushed", False],
"PARTIALLY_SIGNED": ["Partially Signed", False],
"REPLACED": ["Replaced", False],
"RESTORED": ["Restored", False],
"UPDATED": ["Updated", False],
@@ -1039,6 +1322,9 @@ class WillItem(Logger):
self.STATUS["PUSHED"][1] = True
self.STATUS["PUSH_FAIL"][1] = False
if status in ["COMPLETE"]:
self.STATUS["PARTIALLY_SIGNED"][1] = False
return value
def get_status(self, status):
@@ -1059,6 +1345,8 @@ class WillItem(Logger):
self.time = w.get("time", None)
self.change = w.get("change", None)
self.tx_fees = w.get("baltx_fees", 0)
self.sigs_required = int(w.get("sigs_required", 0))
self.sigs_have = int(w.get("sigs_have", 0))
self.father = w.get("Father", None)
self.children = w.get("Children", None)
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
@@ -1095,6 +1383,8 @@ class WillItem(Logger):
"time": self.time,
"change": self.change,
"baltx_fees": self.tx_fees,
"sigs_required": self.sigs_required,
"sigs_have": self.sigs_have,
}
for key in self.STATUS:
try:

View File

@@ -389,7 +389,7 @@ class Willexecutors:
except Exception as e:
_logger.debug(f"error:{e}")
if str(e) == "already present":
raise Willexecutors.AlreadyPresentException()
raise Willexecutors.AlreadyPresentException() from None
out = False
willexecutor["broadcast_status"] = _("Failed")
@@ -485,8 +485,7 @@ class Willexecutors:
Returns:
The same ``willexecutors`` mapping, updated in place.
"""
from concurrent.futures import ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
items = list(willexecutors.items())
if not items:
@@ -566,8 +565,7 @@ class Willexecutors:
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
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
results = {}
@@ -684,8 +682,7 @@ class Willexecutors:
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
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
targets = [(wid, url) for wid, url in items if url]
results = {}

View File

@@ -9,11 +9,12 @@ to "check in" before the locktime expires. This module turns the event data
into an RFC-5545 .ics file and opens it with the OS default application.
"""
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QToolButton
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
class BalCalendarButton(QToolButton):
"""A QToolButton with a dropdown menu for .ics calendar file actions.
@@ -79,7 +80,8 @@ class BalCalendarButton(QToolButton):
path = self._ensure_ics()
if not path:
return
import shlex, subprocess
import shlex
import subprocess
if self._bal_window.bal_plugin.is_basic_mode():
app = self._bal_window.bal_plugin.CALENDAR_APP.default
else:

View File

@@ -27,65 +27,135 @@ from decimal import Decimal
from functools import partial
from typing import Any, Callable, Mapping, Optional, Union
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX,
NLOCKTIME_MIN)
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
from electrum.gui.qt.amountedit import BTCAmountEdit
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
from electrum.gui.qt.my_treeview import MyTreeView
from electrum.gui.qt.password_dialog import PasswordDialog
from electrum.gui.qt.transaction_dialog import TxDialog
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
EnterButton, HelpButton, MessageBoxMixin,
OkButton, TaskThread, WindowModalDialog,
char_width_in_lineedit, getSaveFileName,
import_meta_gui, read_QIcon_from_bytes,
read_QPixmap_from_bytes, webopen)
from electrum.gui.qt.util import (
Buttons,
CancelButton,
ColorScheme,
EnterButton,
HelpButton,
MessageBoxMixin,
OkButton,
TaskThread,
WindowModalDialog,
char_width_in_lineedit,
getSaveFileName,
import_meta_gui,
read_QIcon_from_bytes,
read_QPixmap_from_bytes,
webopen,
)
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
from electrum.payment_identifier import PaymentIdentifier
from electrum.plugin import hook
from electrum.transaction import SerializationError, Transaction, tx_from_any
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
decimal_point_to_base_unit_name, read_json_file,
write_json_file)
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize,
Qt, QTimer, pyqtSignal)
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
QStandardItemModel)
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu,
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame,
QVBoxLayout, QWidget, QDialog)
from electrum.util import (
DECIMAL_POINT,
FileExportFailed,
FileImportFailed,
UserCancelled,
decimal_point_to_base_unit_name,
read_json_file,
write_json_file,
)
from PyQt6.QtCore import (
QDateTime,
QModelIndex,
QPersistentModelIndex,
QSize,
Qt,
QTimer,
pyqtSignal,
)
from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import (
QAbstractItemView,
QAbstractSpinBox,
QCheckBox,
QComboBox,
QDateTimeEdit,
QDialog,
QGridLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMenuBar,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedWidget,
QStyle,
QStyleOptionFrame,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ...core.heirs import (
HEIR_DUST_AMOUNT,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
HeirAmountIsDustException,
Heirs,
WillExecutorFeeTooHighException,
get_op_return_hex,
is_op_return_address,
validate_op_return_hex,
)
# --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
HeirAmountIsDustException, Heirs,
OP_RETURN_PREFIX, is_op_return_address,
get_op_return_hex, validate_op_return_hex,
WillExecutorFeeTooHighException)
from ...core.util import Util
from ...core.will import (AmountException, HeirChangeException,
HeirNotFoundException, NoHeirsException,
NotCompleteWillException, NoWillExecutorNotPresent,
TxFeesChangedException, Will,
WillexecutorChangeException, WillExecutorNotPresent,
WillExpiredException, WillItem, WillPostponedException)
from ...core.willexecutors import Willexecutors
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401
from ...core.will import (
AmountException,
HeirChangeException,
HeirNotFoundException,
NoHeirsException,
NotCompleteWillException,
NoWillExecutorNotPresent,
TxFeesChangedException,
Will,
WillexecutorChangeException,
WillExecutorNotPresent,
WillExpiredException,
WillItem,
WillPostponedException,
)
from ...core.willexecutors import ( # noqa: F401
Willexecutors,
is_onion_url,
is_tor_active,
)
# --- Presentation helpers ---
from .theme import server_status_text, server_status_tooltip, status_color
from .window_utils import (bring_to_front, show_modal, show_on_top,
stop_thread, top_level_of)
from .theme import (
server_status_text,
server_status_tooltip,
signature_suffix,
status_color,
)
from .window_utils import (
bring_to_front,
show_modal,
show_on_top,
stop_thread,
top_level_of,
)
_logger = get_logger(__name__)
class shown_cv:
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
_type = bool
def __init__(self, value):

View File

@@ -17,13 +17,16 @@ the few list classes they reference are imported lazily inside the methods that
use them (see ``lists`` imports below).
"""
from .calendar import BalCalendar, BalCalendarButton
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget,
LockTimeWidget, PercAmountEdit, ThresholdTimeWidget,
WillSettingsWidget, WillWidget, basic_reminder_offsets,
compute_reminder_offsets)
from .calendar import BalCalendar, BalCalendarButton
from .widgets import (
WillSettingsWidget,
WillWidget,
basic_reminder_offsets,
compute_reminder_offsets,
)
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
# imported lazily where needed to avoid a dialogs<->lists import cycle.
@@ -31,9 +34,7 @@ from .calendar import BalCalendar, BalCalendarButton
class BalDialog(QDialog,MessageBoxMixin):
_stopping = False
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
import signal
from PyQt6.QtCore import QMetaObject, Qt
from PyQt6.QtWidgets import QApplication
def handler(signum, frame):
QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection)
@@ -49,7 +50,7 @@ class BalDialog(QDialog,MessageBoxMixin):
self.setWindowTitle(title)
# WindowModalDialog.__init__(self,parent)
self.setWindowIcon(read_QIcon_from_bytes(bal_plugin.read_file(icon)))
def closeEvent(self, event):
self._stopping = True
# NOTE: we deliberately do NOT stop ``self.thread`` here.
@@ -624,7 +625,12 @@ class BalBuildWillDialog(BalDialog):
except CheckAliveError as cae:
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
tx = Will.invalidate_will(
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte,
history_label=self.bal_window.bal_plugin.HISTORY_LABEL.get(),
will_locktime=Will.get_min_locktime(
self.bal_window.willitems,
default_value=self.bal_window.date_to_check,
),
)
if tx:
_logger.debug(
@@ -645,7 +651,14 @@ class BalBuildWillDialog(BalDialog):
Will.check_amounts(
self.bal_window.heirs,
self.bal_window.willexecutors,
self.bal_window.window.wallet.get_utxos(),
Util.get_available_utxos(
self.bal_window.window.wallet,
self.bal_window.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.bal_window.willitems,
default_value=self.bal_window.date_to_check,
),
),
self.bal_window.date_to_check,
self.bal_window.window.wallet.dust_threshold(),
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
@@ -699,9 +712,14 @@ class BalBuildWillDialog(BalDialog):
self.msg_set_checking(_("Postponed: invalidating old will"))
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
return None, Will.invalidate_will(
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte,
history_label=self.bal_window.bal_plugin.HISTORY_LABEL.get(),
will_locktime=Will.get_min_locktime(
self.bal_window.willitems,
default_value=self.bal_window.date_to_check,
),
)
except NoHeirsException as e:
except NoHeirsException:
_logger.debug("no heirs")
self.msg_set_checking("No Heirs")
except NotCompleteWillException as e:
@@ -1477,6 +1495,13 @@ class BalBuildWillDialog(BalDialog):
def on_success_phase2(self, arg=False):
self.thread.stop()
self.bal_window.save_willitems()
# After the whole check/sign/broadcast cycle, keep the wallet's local
# history in sync with the current will state (save the still "New"
# incomplete txs, remove the now-complete ones).
try:
self.bal_window._save_will_to_history()
except Exception as e:
_logger.error(f"save_will_to_history after phase2 failed: {e}")
self.msg_edit_row(_("Finished"))
# Instead of auto-closing after a countdown, let the user decide when to
# dismiss the dialog: they can read the full "Building Will" report at
@@ -1969,10 +1994,18 @@ class BalBuildWillDialog(BalDialog):
class WillDetailDialog(BalDialog):
def __init__(self, bal_window):
self.will = bal_window.willitems
self.threshold = bal_window.will_settings["real_threshold"]
def __init__(self, bal_window, will=None, threshold=None):
# ``will``/``threshold`` are passed when showing an IMPORTED (read-only)
# will. In that case every action button below operates on the imported
# will, never on the live wallet state.
self._external_will = will is not None
self.will = will if self._external_will else bal_window.willitems
if threshold is not None:
self.threshold = threshold
elif self._external_will:
self.threshold = max(wi.tx.locktime for wi in self.will.values())
else:
self.threshold = bal_window.will_settings["real_threshold"]
self.bal_window = bal_window
Will.add_willtree(self.will)
@@ -2004,8 +2037,14 @@ class WillDetailDialog(BalDialog):
b.clicked.connect(self.export_will)
hlayout.addWidget(b)
b = QPushButton(_("Invalidate"))
b.clicked.connect(bal_window.invalidate_will)
b.clicked.connect(self.invalidate_will)
hlayout.addWidget(b)
self.merge_button = None
if self._external_will:
b = QPushButton(_("Merge"))
b.clicked.connect(self.merge_will)
hlayout.addWidget(b)
self.merge_button = b
self.vlayout.addWidget(w)
self.paint_scroll_area()
@@ -2026,22 +2065,48 @@ class WillDetailDialog(BalDialog):
self.scrollbox = QScrollArea()
viewport = QWidget(self.scrollbox)
self.willlayout = QVBoxLayout(viewport)
self.detailsWidget = WillWidget(parent=self)
self.detailsWidget = WillWidget(parent=self, will=self.will)
self.willlayout.addWidget(self.detailsWidget)
self.scrollbox.setWidget(viewport)
viewport.setLayout(self.willlayout)
def ask_password_and_sign_transactions(self):
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
self.bal_window.ask_password_and_sign_transactions(
callback=self.update, will=self.will if self._external_will else None
)
self.update()
def broadcast_transactions(self):
self.bal_window.broadcast_transactions()
self.bal_window.broadcast_transactions(
will=self.will if self._external_will else None
)
self.update()
def export_will(self):
self.bal_window.export_will()
self.bal_window.export_will(will=self.will if self._external_will else None)
def invalidate_will(self):
self.bal_window.invalidate_will(
will=self.will if self._external_will else None
)
def merge_will(self):
"""Merge the imported will into the live will and switch to it.
The merge is performed by :meth:`BalWindow.merge_will` (the same
common method used by the tools-menu "Merge" action). Afterwards the
dialog stops showing the read-only imported will and operates on the
live wallet willitems directly, so any further Sign/Broadcast/Export/
Invalidate action targets the saved will items.
"""
self.bal_window.merge_will(self.will)
self._external_will = False
self.will = self.bal_window.willitems
self.threshold = self.bal_window.will_settings["real_threshold"]
if self.merge_button:
self.merge_button.hide()
self.update()
def toggle_replaced(self):
self.bal_window.bal_plugin.hide_replaced()
@@ -2060,7 +2125,8 @@ class WillDetailDialog(BalDialog):
self.update()
def update(self):
self.will = self.bal_window.willitems
if not self._external_will:
self.will = self.bal_window.willitems
pos = self.vlayout.indexOf(self.scrollbox)
self.vlayout.removeWidget(self.scrollbox)
self.paint_scroll_area()

View File

@@ -14,12 +14,13 @@ construction) for all business actions, so the heavy logic stays in ``window``
and ``dialogs``.
"""
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
from PyQt6.QtWidgets import QMessageBox
from PyQt6.QtWidgets import QStyledItemDelegate, QLineEdit as _QLineEdit
from .dialogs import BalBuildWillDialog, BalDialog
from .widgets import BalCheckBox, WillSettingsWidget
class HeirListWidget(MyTreeView, MessageBoxMixin):
@@ -194,7 +195,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
set_current = QPersistentModelIndex(idx)
try:
self.will_settings_widget.on_locktime_change()
except Exception as e:
except Exception:
pass
self.set_current_idx(set_current)
# FIXME refresh loses sort order; so set "default" here:
@@ -214,15 +215,15 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
menu.addAction(_("Import"), self.bal_window.import_heirs)
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
newHeirButton = QPushButton(_("New Heir"))
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog)
new_heir_button = QPushButton(_("New Heir"))
new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
widget = QWidget(self)
layout = QHBoxLayout(widget)
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
layout.addWidget(self.will_settings_widget)
layout.addWidget(newHeirButton)
layout.addWidget(new_heir_button)
toolbar.insertWidget(2, widget)
@@ -281,7 +282,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
except Exception as e:
except Exception:
pass
self.setSortingEnabled(True)
@@ -320,13 +321,6 @@ class PreviewList(MyTreeView, MessageBoxMixin):
_("check ").format(column_title),
lambda: self.check_transactions(selected_keys),
)
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
try:
self.importaction = self.menu.addAction(
_("Import"), self.import_will
)
except Exception:
pass
menu.addSeparator()
menu.addAction(
@@ -395,8 +389,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
if bal_tx.we:
we = bal_tx.we["url"]
labels[self.Columns.WILLEXECUTOR] = we
status = bal_tx.status
if len(bal_tx.status) > 53:
status = bal_tx.status + signature_suffix(bal_tx)
if len(status) > 53:
status = "...{}".format(status[-50:])
labels[self.Columns.STATUS] = status
# Dedicated, always-readable label describing whether the inheritance
@@ -473,8 +467,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
menu.addAction(_("Export"), self.export_will)
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
self.importaction = menu.addAction(_("Import"), self.import_will)
menu.addAction(_("Import"), self.import_will_into_details)
menu.addAction(_("Merge"), self.merge_will)
menu.addAction(_("Broadcast"), self.broadcast)
menu.addAction(_("Check"), self.check)
menu.addAction(_("Invalidate"), self.invalidate_will)
@@ -546,8 +540,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
self.bal_window.export_will()
self.update()
def import_will(self):
self.bal_window.import_will()
def import_will_into_details(self):
self.bal_window.import_will_into_details()
def merge_will(self):
self.bal_window.merge_will_ui()
def ask_password_and_sign_transactions(self):
self.bal_window.ask_password_and_sign_transactions(callback=self.update)

View File

@@ -15,14 +15,17 @@ and cached in ``self.bal_windows``.
"""
from electrum.gui.qt.main_window import StatusBarButton
from PyQt6.QtWidgets import QLayout
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .common import read_QIcon_from_bytes
from .common import ( # underscore names are not re-exported by "import *"
_,
_logger,
read_QIcon_from_bytes,
)
from .dialogs import BalDialog
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
from .window import BalWindow
from .dialogs import BalDialog
from PyQt6.QtWidgets import QLayout
def _window_key(window):
@@ -88,9 +91,11 @@ class Plugin(BalPlugin):
except Exception:
return []
try:
from electrum.gui.qt.plugins_dialog import PluginsDialog
from electrum.gui.qt.plugins_dialog import (
PluginsDialog as plugins_dialog, # noqa: N813
)
except Exception:
PluginsDialog = None
plugins_dialog = None
app = QApplication.instance()
if app is None:
return []
@@ -106,7 +111,7 @@ class Plugin(BalPlugin):
for w in app.topLevelWidgets():
try:
is_match = False
if PluginsDialog is not None and isinstance(w, PluginsDialog):
if plugins_dialog is not None and isinstance(w, plugins_dialog):
is_match = True
elif type(w).__name__ == "PluginsDialog":
is_match = True
@@ -142,17 +147,17 @@ class Plugin(BalPlugin):
each is guarded independently.
"""
try:
from PyQt6.QtWidgets import QDialog
from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
except Exception:
QDialog = None
qdialog = None
# 1) reject() / done(): the reliable way to end an exec() modal loop.
if QDialog is not None and isinstance(d, QDialog):
if qdialog is not None and isinstance(d, qdialog):
try:
d.reject()
except Exception as e:
_logger.debug("reject() failed: {}".format(e))
try:
d.done(QDialog.DialogCode.Rejected)
d.done(qdialog.DialogCode.Rejected)
except Exception as e:
_logger.debug("done() failed: {}".format(e))
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
@@ -172,9 +177,9 @@ class Plugin(BalPlugin):
it and closes it themselves (it must not linger in the background).
"""
try:
from PyQt6.QtCore import QTimer
from PyQt6.QtCore import QTimer as qtimer # noqa: N813
except Exception:
QTimer = None
qtimer = None
# Schedule of retry delays (ms) measured from each call.
retry_delays = [400, 800, 1500]
dialogs = Plugin._find_plugins_manager_dialogs()
@@ -190,8 +195,8 @@ class Plugin(BalPlugin):
if not still_open:
_logger.info("plugins dialog closed successfully")
return
if attempt < len(retry_delays) and QTimer is not None:
QTimer.singleShot(
if attempt < len(retry_delays) and qtimer is not None:
qtimer.singleShot(
retry_delays[attempt],
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
)
@@ -505,8 +510,10 @@ class Plugin(BalPlugin):
lbl_event_description, edit_event_description, help_event_description,
lbl_calendar_app, edit_calendar_app, help_calendar_app,
lbl_auto_sign, heir_auto_sign, help_auto_sign,
lbl_save_history, heir_save_history, help_save_history,
lbl_history_label, edit_history_label, help_history_label,
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
reset_btn_auto_sign):
reset_btn_11, reset_btn_12, reset_btn_auto_sign):
w.setVisible(not basic)
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
# a real USER TYPE change (not inside update_all/CHECK), so pressing
@@ -532,6 +539,20 @@ class Plugin(BalPlugin):
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
edit_calendar_app.setMinimumWidth(360)
# "Save inheritance transactions in wallet history" checkbox + label
# field (History persistence). When the checkbox is ON, the valid will
# transactions are saved into the wallet's LOCAL history (the History
# tab) after each check, each tagged with the label below. The label
# field is disabled while the checkbox is off, so the user cannot set a
# label for a feature that is not active.
def on_save_history_change():
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
heir_save_history = BalCheckBox(self.SAVE_HISTORY, on_click=on_save_history_change)
edit_history_label = BalLineEdit(self.HISTORY_LABEL)
edit_history_label.setMinimumWidth(360)
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
def _make_reset_btn(cfg, widget, kind):
"""Return a small ``↺`` button that resets a single setting."""
btn = QPushButton("\u21ba")
@@ -742,6 +763,37 @@ class Plugin(BalPlugin):
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
grid.addWidget(_hide_if_basic(reset_btn_10), 11, 3)
# Save-in-history toggle and history label: advanced-only rows. The
# label field is disabled while the checkbox is off (see
# on_save_history_change above).
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
help_save_history = HelpButton(
"After each check, save the valid will transactions into the "
"wallet's local history (the History tab), each with a label.\n"
"The label may contain the variable:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_save_history), 12, 0)
grid.addWidget(_hide_if_basic(heir_save_history), 12, 1)
grid.addWidget(_hide_if_basic(help_save_history), 12, 2)
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
grid.addWidget(_hide_if_basic(reset_btn_11), 12, 3)
lbl_history_label = QLabel(_("History label"))
help_history_label = HelpButton(
"Label applied to the will transactions saved into the wallet's "
"local history.\n"
"Variables:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_history_label), 13, 0)
grid.addWidget(_hide_if_basic(edit_history_label), 13, 1)
grid.addWidget(_hide_if_basic(help_history_label), 13, 2)
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
grid.addWidget(_hide_if_basic(reset_btn_12), 13, 3)
# NOTE: the ADVANCED-only widgets above have ALREADY been given their
# correct initial visibility inline (via _hide_if_basic) BEFORE being
# added to the grid. The old code did the opposite - it added them
@@ -749,12 +801,12 @@ class Plugin(BalPlugin):
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
# setVisible() loop here.
grid.addWidget(heir_repush, 12, 0)
grid.addWidget(heir_repush, 14, 0)
grid.addWidget(
HelpButton(
"Broadcast all transactions to willexecutors including those already pushed"
),
12,
14,
2,
)
@@ -788,6 +840,8 @@ class Plugin(BalPlugin):
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
(self.WELIST_SERVER, edit_welist_server, "line"),
(self.CALENDAR_APP, edit_calendar_app, "line"),
(self.SAVE_HISTORY, heir_save_history, "check"),
(self.HISTORY_LABEL, edit_history_label, "line"),
]
for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config.
@@ -808,6 +862,10 @@ class Plugin(BalPlugin):
widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0
)
# Re-sync the history-label field's enabled state after a reset: the
# reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again.
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
# Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are)
# reflects the reset values.

View File

@@ -54,12 +54,31 @@ def status_color(will_item) -> str:
return "#e83845" # red - failed to push to will-executor
elif will_item.get_status("PUSHED"):
return "#73f3c8" # teal - pushed to will-executor
elif will_item.get_status("PARTIALLY_SIGNED"):
return "#ffb347" # amber - some signatures present, more needed
elif will_item.get_status("COMPLETE"):
return "#2bc8ed" # blue - signed
else:
return _DEFAULT_COLOR
def signature_suffix(will_item) -> str:
"""Return the ``" (added/required)"`` suffix for a non-signed will item.
Used by the transaction list and the detail view to show how many of the
required signatures have already been added, e.g. ``"(1/2)"`` for a 2-of-3
transaction carrying one signature. Returns ``""`` for signed transactions
or when the required count is unknown (no descriptor available yet).
"""
if will_item.get_status("COMPLETE"):
return ""
required = int(getattr(will_item, "sigs_required", 0) or 0)
added = int(getattr(will_item, "sigs_have", 0) or 0)
if not required:
return ""
return " ({}/{})".format(added, required)
def server_status_text(will_item) -> str:
"""Return a short, human-readable label describing the state of a will
item on the will-executor servers (the online inheritance backup).

View File

@@ -18,9 +18,9 @@ Contents:
* WillWidget - single will-tx box
"""
from .calendar import BalCalendar, BalCalendarButton
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .calendar import BalCalendar, BalCalendarButton
def compute_reminder_offsets(days, count):
@@ -690,7 +690,7 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
return
try:
x = int(x)
except Exception as e:
except Exception:
x = QDateTime.currentDateTime().timestamp()
finally:
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
@@ -1395,15 +1395,15 @@ class PercAmountEdit(BTCAmountEdit):
if self.base_unit:
panel = QStyleOptionFrame()
self.initStyleOption(panel)
textRect = self.style().subElementRect(
text_rect = self.style().subElementRect(
QStyle.SubElement.SE_LineEditContents, panel, self
)
textRect.adjust(2, 0, -10, 0)
text_rect.adjust(2, 0, -10, 0)
painter = QPainter(self)
painter.setPen(ColorScheme.GRAY.as_color())
if len(self.text()) == 0:
painter.drawText(
textRect,
text_rect,
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
self.base_unit() + " or perc value",
)
@@ -1482,11 +1482,11 @@ class BalSpinBox(QSpinBox):
class WillWidget(QWidget):
def __init__(self, father=None, parent=None):
def __init__(self, father=None, parent=None, will=None):
super().__init__()
vlayout = QVBoxLayout()
self.setLayout(vlayout)
self.will = parent.bal_window.willitems
self.will = will if will is not None else parent.bal_window.willitems
self._bal_parent = parent
for w in self.will:
if (
@@ -1513,7 +1513,10 @@ class WillWidget(QWidget):
willpushbutton = QPushButton(w)
willpushbutton.clicked.connect(
partial(self._bal_parent.bal_window.show_transaction, txid=w)
partial(
self._bal_parent.bal_window.show_transaction,
tx=self.will[w].tx,
)
)
detaillayout.addWidget(willpushbutton)
locktime = str(BalTimestamp(self.will[w].tx.locktime))
@@ -1535,7 +1538,9 @@ class WillWidget(QWidget):
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
detaillayout.addWidget(
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
)
detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
for heir in self.will[w].heirs:
@@ -1570,6 +1575,6 @@ class WillWidget(QWidget):
detailw.setPalette(pal)
hlayout.addWidget(detailw)
hlayout.addWidget(WillWidget(w, parent=parent))
hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))

View File

@@ -18,11 +18,16 @@ import threading
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget
from .dialogs import (BalBlockingWaitingDialog, BalBuildWillDialog, BalDialog,
BalWaitingDialog, BalWizardDialog, WillDetailDialog,
WillExecutorDialog)
from .lists import HeirListWidget, PreviewList, WillExecutorWidget
from .dialogs import (
BalBuildWillDialog,
BalDialog,
BalWaitingDialog,
BalWizardDialog,
WillDetailDialog,
WillExecutorDialog,
)
from .lists import HeirListWidget, PreviewList
from .widgets import LockTimeWidget, PercAmountEdit
class BalWindow:
@@ -350,6 +355,12 @@ class BalWindow:
will = self.build_inheritance_transaction(
ignore_duplicate=ignore_duplicate, keep_original=keep_original
)
# Persist the freshly prepared transactions into the wallet's local
# history (when SAVE_HISTORY is enabled). This runs on every successful
# prepare -- including the "Prepare" menu action -- so the New txs show
# up in History immediately. Abort paths return None and are skipped.
if will:
self._save_will_to_history()
return will
def delete_not_valid(self, txid, s_utxo):
@@ -388,12 +399,20 @@ class BalWindow:
# date_to_check already carries the correct reference timestamp for
# the current mode (the Check Alive in ADVANCED, or "now" in BASIC -
# see init_class_variables). So build the will directly against it;
# no per-mode branch is needed here anymore.
# no per-mode branch is needed here anymore. The available-UTXO view
# restores coins that a newer, wallet-local will tx (stored in the
# history with a later locktime) nominally spent.
txs = self.heirs.get_transactions(
self.bal_plugin,
self.window.wallet,
self.will_settings["baltx_fees"],
None,
Util.get_available_utxos(
self.window.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
),
self.date_to_check,
)
@@ -429,17 +448,75 @@ class BalWindow:
return self.willitems
def check_will(self):
return Will.is_will_valid(
result = Will.is_will_valid(
self.willitems,
self.date_to_check,
self.will_settings["baltx_fees"],
self.window.wallet.get_utxos(),
Util.get_available_utxos(
self.window.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
),
heirs=self.heirs,
willexecutors=self.willexecutors,
self_willexecutor=self.no_willexecutor,
wallet=self.wallet,
callback_not_valid_tx=self.delete_not_valid,
)
return result
def _save_will_to_history(self):
"""Persist the current will state into the wallet's LOCAL history.
Runs after the will has been prepared/built/signed/checked (the
"Prepare" action, the check dialog's phase 2 and the manual Sign
action). When the SAVE_HISTORY setting is enabled,
``Will.save_valid_transactions_to_history`` stores the still "New" (not
fully-signed) transactions under the configured label and removes
entries for fully-signed ("Complete") and stale ones. The wallet tabs
are then re-rendered through ``_refresh_after_history_save``.
This must never raise: history persistence is a convenience on top of
the will flows, so any failure is logged and ignored.
"""
try:
if not bool(self.bal_plugin.SAVE_HISTORY.get()):
return
Will.save_valid_transactions_to_history(
self.willitems,
self.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
)
except Exception as e:
_logger.error(f"save_will_to_history failed: {e}")
self._schedule_history_refresh()
def _schedule_history_refresh(self):
"""Re-render the wallet tabs after the local history has changed.
The actual refresh must run on the GUI thread (``HistoryModel.refresh``
asserts that), so the call is marshalled through ``QTimer.singleShot``.
Used after saving/removing will transactions in the local history and
after a will rebuild, regardless of the calling thread.
"""
QTimer.singleShot(0, self._refresh_after_history_save)
def _refresh_after_history_save(self):
"""Re-render the wallet tabs after saving txs to the local history.
``update_tabs`` refreshes history plus the receive/send/address/coins
lists; ``update_status`` refreshes the status-bar balance, which
``update_tabs`` does not touch. When ``update_tabs`` is not available we
fall back to refreshing just the History tab.
"""
if hasattr(self.window, "update_tabs"):
self.window.update_tabs()
elif hasattr(self.window, "history_list"):
self.window.history_list.update()
if hasattr(self.window, "update_status"):
self.window.update_status()
def show_message(self, text):
self.window.show_message(text)
@@ -589,7 +666,13 @@ class BalWindow:
Will.check_amounts(
self.heirs,
self.willexecutors,
self.window.wallet.get_utxos(),
Util.get_available_utxos(
self.window.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
),
self.date_to_check,
self.window.wallet.dust_threshold(),
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
@@ -735,8 +818,7 @@ class BalWindow:
)
)
self.window.history_list.update()
self.window.utxo_list.update()
self._schedule_history_refresh()
# Guide the user: the inheritance was just (re)built and is now
# in the "New" state, so it must be SIGNED and then BROADCAST
@@ -806,7 +888,7 @@ class BalWindow:
raise Exception(_("no tx"))
return self.show_transaction_real(tx, parent=parent)
def invalidate_will(self):
def invalidate_will(self, will=None):
def on_success(result):
if result:
self.show_message(
@@ -823,16 +905,27 @@ class BalWindow:
def on_failure(exec_info):
log_error(exec_info, self.bal_window)
willitems = will if will is not None else self.willitems
fee_per_byte = self.will_settings.get("baltx_fees", 1)
task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte)
task = partial(
Will.invalidate_will,
willitems,
self.wallet,
fee_per_byte,
history_label=self.bal_plugin.HISTORY_LABEL.get(),
will_locktime=Will.get_min_locktime(
willitems, default_value=self.date_to_check
),
)
msg = _("Calculating Transactions")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def sign_transactions(self, password):
def sign_transactions(self, password, will=None):
try:
willitems = will if will is not None else self.willitems
txs = {}
signed = None
tosign = None
@@ -843,8 +936,8 @@ class BalWindow:
msg = _(f"signed: {signed}\n")
return msg + _(f"signing: {tosign}")
for txid in Will.only_valid(self.willitems):
wi = self.willitems[txid]
for txid in Will.only_valid(willitems):
wi = willitems[txid]
tx = copy.deepcopy(wi.tx)
if wi.get_status("COMPLETE"):
txs[txid] = tx
@@ -856,8 +949,8 @@ class BalWindow:
pass
for txin in tx.inputs():
prevout = txin.prevout.to_json()
if prevout[0] in self.willitems:
change = self.willitems[prevout[0]].tx.outputs()[prevout[1]]
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
@@ -874,6 +967,16 @@ class BalWindow:
if tx.is_complete():
# is_complete = True
wi.set_status("COMPLETE", True)
# Refresh the per-item signature counts from the freshly signed
# partial tx: at this point the signatures are still present
# (before any finalization), so the will list can show the real
# "added/required" count (e.g. "1/2" for a multisig).
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}")
txs[txid] = tx
except Exception:
return None
@@ -940,16 +1043,29 @@ class BalWindow:
# re-wire them if this same window is reused for another wallet.
self._menubar_initialized = False
def ask_password_and_sign_transactions(self, callback=None):
def ask_password_and_sign_transactions(self, callback=None, will=None):
external = will is not None
willitems = will if external else self.willitems
def on_success(txs):
if txs:
for txid, tx in txs.items():
self.willitems[txid].tx = copy.deepcopy(tx)
self.will[txid] = self.willitems[txid].to_dict()
willitems[txid].tx = copy.deepcopy(tx)
if not external:
self.will[txid] = willitems[txid].to_dict()
try:
self.will_list_widget.update()
except Exception:
pass
Will.check_signatures(willitems, self.wallet)
except Exception as e:
_logger.error(f"check_signatures after signing failed: {e}")
if not external:
try:
self.will_list_widget.update()
except Exception:
pass
# After signing, keep the local history in sync (save the still
# incomplete "New" txs, remove the now-complete ones).
if not external:
self._save_will_to_history()
if callback:
try:
callback()
@@ -960,16 +1076,19 @@ class BalWindow:
log_error(exec_info, self.bal_window)
password = self.get_wallet_password()
task = partial(self.sign_transactions, password)
task = partial(self.sign_transactions, password, will=will)
msg = _("Signing transactions...")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def broadcast_transactions(self, force=False):
def broadcast_transactions(self, force=False, will=None):
external = will is not None
def on_success(sulcess):
self.will_list_widget.update()
if not external:
self.will_list_widget.update()
if sulcess:
_logger.info("error, some transaction was not sent")
self.show_warning(_("Some transaction was not broadcasted"))
@@ -994,15 +1113,16 @@ class BalWindow:
# _logger.error("lasti:", tb.tb_lasti)
# tb = tb.tb_next
task = partial(self.push_transactions_to_willexecutors, force)
task = partial(self.push_transactions_to_willexecutors, force, will=will)
msg = _("Selecting Will-Executors")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def push_transactions_to_willexecutors(self, force=False):
willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems, force=force)
def push_transactions_to_willexecutors(self, force=False, will=None):
willitems = will if will is not None else self.willitems
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
def getMsg(willexecutors):
msg = "Broadcasting Transactions to Will-Executors:\n"
@@ -1031,11 +1151,11 @@ class BalWindow:
willexecutor["broadcast_status"] = _("checking...")
elif ok:
for wid in willexecutor.get("txsids", []):
self.willitems[wid].set_status("PUSHED", True)
willitems[wid].set_status("PUSHED", True)
willexecutor["broadcast_status"] = _("Success")
else:
for wid in willexecutor.get("txsids", []):
self.willitems[wid].set_status("PUSH_FAIL", True)
willitems[wid].set_status("PUSH_FAIL", True)
error["flag"] = True
willexecutor["broadcast_status"] = _("Failed")
willexecutor.pop("txs", None)
@@ -1060,54 +1180,171 @@ class BalWindow:
return
self.waiting_dialog.update(
"checking {} - {} : {}".format(
self.willitems[wid].we["url"], wid, "Waiting"
willitems[wid].we["url"], wid, "Waiting"
)
)
w = self.willitems[wid]
w = willitems[wid]
w.set_check_willexecutor(
Willexecutors.check_transaction(wid, w.we["url"])
)
self.waiting_dialog.update(
"checked {} - {} : {}".format(
self.willitems[wid].we["url"],
willitems[wid].we["url"],
wid,
self.willitems[wid].get_status("CHECKED"),
willitems[wid].get_status("CHECKED"),
)
)
if error["flag"]:
return True
def export_json_file(self, path):
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)
def export_json_file(self, path, will=None):
if will is None:
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)
else:
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
def export_will(self):
def export_will(self, will=None):
try:
export_meta_gui(self.window, "will.json", self.export_json_file)
export_meta_gui(
self.window, "will.json", partial(self.export_json_file, will=will)
)
except Exception as e:
self.show_error(str(e))
raise e
def import_will(self):
def sulcess():
def merge_will(self, imported):
"""Merge imported will items into the live will.
Both the tools-menu "Merge" action and the details-dialog "Merge"
button go through this single method.
For a transaction that already exists in the live will the live
WillItem is kept (never replaced): only the operational statuses
(signed/pushed/checked/mempool/confirmed) that are True in the
imported item are carried over. When the live transaction is not
yet signed the imported transaction is merged into it (signatures
are combined when both are the same unsigned tx, otherwise the
transaction is substituted); an already-signed live transaction is
left untouched. Transactions that are new are added wholesale.
After the merge a local validity check recomputes the
valid/invalidated/replaced statuses (no server contact, no expiry
raise).
"""
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()
# Local validity check: recompute valid/invalidated/replaced statuses.
try:
Will.add_willtree(self.willitems)
bal_plugin = getattr(self, "bal_plugin", None)
history_label = (
bal_plugin.HISTORY_LABEL.get() if bal_plugin is not None else None
)
all_utxos = Util.get_available_utxos(
self.wallet,
history_label,
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
)
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:
log_error(e, self.bal_window)
self.save_willitems()
self.update_all()
def merge_will_from_file(self, path):
try:
willitems = self._load_will_file(path)
except Exception as e:
raise FileImportFailed(_("Invalid will file: {}").format(e)) from None
Will.normalize_will(willitems, self.wallet)
self.merge_will(willitems)
def merge_will_ui(self):
def on_success():
self.will_list_widget.update_will(self.willitems)
import_meta_gui(self.window, _("will"), self.import_json_file, sulcess)
import_meta_gui(self.window, _("will"), self.merge_will_from_file, on_success)
def import_json_file(self, path):
try:
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)
self.update_will(willitems)
except Exception as e:
raise e
# raise FileImportFailed(_("Invalid will file"))
def import_will_into_details(self):
"""Import a will file and show it in a WillDetails window.
Unlike the "Merge" actions (which merge the file into the active
will), this is a read-only preview: the parsed will is shown in a
:class:`WillDetailDialog` and the live wallet state is never touched.
The dialog's Sign/Broadcast/Export/Invalidate buttons operate on the
imported will only, and its Merge button merges the imported will
into the live one.
"""
imported = {}
def on_file(path):
try:
willitems = self._load_will_file(path)
except Exception as e:
self.show_error(_("Invalid will file: {}").format(e))
return
# Attach wallet/input info so the imported txs can be signed and
# broadcast (mirrors what merge_will_from_file does).
Will.normalize_will(willitems, self.wallet)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
imported.update(willitems)
def on_success():
if not imported:
return
d = WillDetailDialog(self, will=imported)
show_on_top(d)
import_meta_gui(self.window, _("will"), on_file, on_success)
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 check_transactions_task(self, will):
start = time.time()
@@ -1502,7 +1739,13 @@ class BalWindow:
for _wid, _w in list(self.willitems.items())[:3]:
_logger.debug(f"NoneType_debug willitems[{_wid}] type={type(_w).__name__}")
Will.add_willtree(self.willitems)
all_utxos = self.wallet.get_utxos()
all_utxos = Util.get_available_utxos(
self.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
)
utxos_list = Will.utxos_strs(all_utxos)
Will.check_invalidated(self.willitems, utxos_list, self.wallet)

View File

@@ -5,3 +5,15 @@ target-version = "py312"
[tool.ruff.lint]
select =["E", "W", "F", "I", "N", "B"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"bal/gui/qt/*.py" = ["F403", "F405"] # intentional `from .common import *` hub
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via `import *`
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
"bal/gui/qt/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
"bal/gui/qt/window.py" = ["N802"] # getMsg
"bal/core/heirs.py" = ["N818", "N802"] # public exception names + buildTransactions API
"bal/core/will.py" = ["N818"] # public exception names
"bal/core/willexecutors.py" = ["N818"] # public exception names
"tests/*.py" = ["N802", "E402"] # deliberate UPPER_CASE helpers + sys.path-before-import

View File

@@ -33,7 +33,8 @@ def _active_source_without_strings(module) -> str:
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
self.spans.append((node.lineno, node.end_lineno))
self.generic_visit(node)
s = _S(); s.visit(tree)
s = _S()
s.visit(tree)
drop = set()
for a, b in s.spans:
drop.update(range(a, b + 1))
@@ -45,12 +46,13 @@ def _active_source_without_strings(module) -> str:
def main(pkg: str) -> int:
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
app = QApplication.instance() or QApplication(sys.argv)
_app = QApplication.instance() or QApplication(sys.argv)
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
# top_level_of: returns the top-level container of a child widget
w = QWidget(); child = QWidget(w)
w = QWidget()
child = QWidget(w)
assert wu.top_level_of(child) is w
assert wu.top_level_of(None) is None
print("[OK] top_level_of")

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@ N = 8 # number of servers
def main():
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
W = we_mod.Willexecutors
we_cls = we_mod.Willexecutors
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
def slow_get_info(url, we, **kwargs):
@@ -43,8 +43,8 @@ def main():
we["status"] = 200
return we
orig_get_info = W.get_info_task
W.get_info_task = staticmethod(slow_get_info)
orig_get_info = we_cls.get_info_task
we_cls.get_info_task = staticmethod(slow_get_info)
try:
wes = {}
for i in range(N):
@@ -57,7 +57,7 @@ def main():
seen.append((url, ok))
start = time.time()
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
elapsed = time.time() - start
# Sequential would take ~ N * SLOW. Parallel must be far less.
@@ -81,15 +81,15 @@ def main():
assert we["status"] == "KO", (url, we)
print("[OK] ping results written back into the willexecutors mapping")
finally:
W.get_info_task = orig_get_info
we_cls.get_info_task = orig_get_info
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
def slow_push(we, **kwargs):
time.sleep(SLOW)
return "fail" not in we["url"]
orig_push = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push)
orig_push = we_cls.push_transactions_to_willexecutor
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
try:
wes = {}
for i in range(N):
@@ -106,7 +106,7 @@ def main():
pushed.append((url, ok))
start = time.time()
results = W.push_transactions_parallel(wes, on_each=on_each_push,
results = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
max_workers=N)
elapsed = time.time() - start
@@ -117,11 +117,11 @@ def main():
f"(sequential would be ~{sequential:.2f}s)")
assert len(results) == N, results
for url, (ok, exc) in results.items():
for url, (ok, _exc) in results.items():
assert ok == ("good" in url), (url, ok)
print("[OK] push results correct for every server")
finally:
W.push_transactions_to_willexecutor = orig_push
we_cls.push_transactions_to_willexecutor = orig_push
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
def hanging_push(we, **kwargs):
@@ -129,8 +129,8 @@ def main():
time.sleep(10)
return True
orig_push2 = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(hanging_push)
orig_push2 = we_cls.push_transactions_to_willexecutor
we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
try:
wes = {
"https://fast.example": {
@@ -146,7 +146,7 @@ def main():
return True
time.sleep(10)
return True
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
timed_out = []
@@ -154,7 +154,7 @@ def main():
timed_out.append(url)
start = time.time()
W.push_transactions_parallel(
we_cls.push_transactions_parallel(
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
)
elapsed = time.time() - start
@@ -163,7 +163,7 @@ def main():
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
f"hung server reported via on_timeout")
finally:
W.push_transactions_to_willexecutor = orig_push2
we_cls.push_transactions_to_willexecutor = orig_push2
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
# The elapsed-time counter is driven by an on_tick callback called from the
@@ -175,8 +175,8 @@ def main():
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
return True
orig_push3 = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push2)
orig_push3 = we_cls.push_transactions_to_willexecutor
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
try:
wes = {
"https://tick.example": {
@@ -191,7 +191,7 @@ def main():
ticks.append(time.time())
tick_threads.add(threading.current_thread())
W.push_transactions_parallel(
we_cls.push_transactions_parallel(
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
)
# ~3s push with 0.5s ticks => at least a few ticks.
@@ -202,7 +202,7 @@ def main():
)
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
finally:
W.push_transactions_to_willexecutor = orig_push3
we_cls.push_transactions_to_willexecutor = orig_push3
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
# Pressing "Check" verifies each will-executor still holds its tx. This used
@@ -214,8 +214,8 @@ def main():
time.sleep(SLOW)
return {"tx": "ok"} if "good" in url else None
orig_check = W.check_transaction
W.check_transaction = staticmethod(slow_check)
orig_check = we_cls.check_transaction
we_cls.check_transaction = staticmethod(slow_check)
try:
targets = []
for i in range(N):
@@ -228,7 +228,7 @@ def main():
checked.append((wid, res))
start = time.time()
results = W.check_transactions_parallel(
results = we_cls.check_transactions_parallel(
targets, on_each=on_each_check, max_workers=N
)
elapsed = time.time() - start
@@ -239,7 +239,7 @@ def main():
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
f"(sequential would be ~{sequential:.2f}s)")
finally:
W.check_transaction = orig_check
we_cls.check_transaction = orig_check
# 2d-bis) global deadline + on_tick from the calling thread
def hanging_check(txid, url, **kwargs):
@@ -248,8 +248,8 @@ def main():
time.sleep(10)
return {"tx": "ok"}
orig_check2 = W.check_transaction
W.check_transaction = staticmethod(hanging_check)
orig_check2 = we_cls.check_transaction
we_cls.check_transaction = staticmethod(hanging_check)
try:
targets = [
("idf", "https://fast.example"),
@@ -268,7 +268,7 @@ def main():
tick_threads.add(threading.current_thread())
start = time.time()
W.check_transactions_parallel(
we_cls.check_transactions_parallel(
targets, max_workers=2, deadline=2.0,
on_timeout=on_timeout_check, on_tick=on_tick_check,
tick_interval=0.5,
@@ -282,7 +282,7 @@ def main():
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
f"fired {len(ticks)}x from the calling thread")
finally:
W.check_transaction = orig_check2
we_cls.check_transaction = orig_check2
# ---- 3) the wizard's loop_push must use the parallel helper ----
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.

View File

@@ -17,8 +17,8 @@ import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
# Same colors as BalBuildWillDialog
COLOR_WARNING = "#cfa808"

View File

@@ -17,10 +17,15 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
)
from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
COLOR_OK = "#05ad05"

View File

@@ -21,8 +21,8 @@ import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"

View File

@@ -20,12 +20,17 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
QLabel,
)
from PyQt6.QtCore import QSize # noqa: E402
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
from PyQt6.QtCore import QSize, Qt # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QWidget,
)
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
"wizard.png")

View File

@@ -27,12 +27,19 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
QLineEdit, QSpinBox, QLabel,
)
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
def _char_w():

View File

@@ -65,7 +65,8 @@
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl": [],
"bcrt1qwhjyltkpkyqj2k8zd6zjq2uu49tpu03f4sm7j0": [],
"bcrt1qygktltj8sjkus96k0atzgr8srcy658axnpj82x": [],
"bcrt1qyjpmlr7qehqedzhkzsx6xrvr9ey6455yur2pqx": []
"bcrt1qyjpmlr7qehqedzhkzsx6xrvr9ey6455yur2pqx": [],
"bcrt1qyx3e4qwguyr70g9wdva4dgpp2pdpauj9dht5ga": []
},
"addresses": {
"change": [
@@ -80,7 +81,8 @@
"bcrt1q59nyxchtw4eajltn97sds6whdj4p2rg6uaygfq",
"bcrt1qw3ljqdscx64qk9lxke3evz8scukj4zf8l5mptt",
"bcrt1qrkwdul9mws0hu298hvtd5usce9haypwmt8zhts",
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl"
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl",
"bcrt1qyx3e4qwguyr70g9wdva4dgpp2pdpauj9dht5ga"
],
"receiving": [
"bcrt1qkxnsj8xymkk4mahea5ry8lgpau0w5dqgs0ezxc",
@@ -121,22 +123,22 @@
"aaaa": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"34%",
"5y"
1813204800
],
"lucia": [
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
"32%",
"5y"
1813204800
],
"mario": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"35%",
"5y"
1813204800
],
"mario2": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
40000,
"5y"
1813204800
]
},
"imported_channel_backups": {},
@@ -152,6 +154,7 @@
"xpub": "vpub5VpWCxVNP1qcP3aDcBMryrhaCECvFte8T3eT8BAUSGW4LuHoVEUNaKKPGsuN8H2MFTMmtZHnokVMB6c46MFiCzyYHwmsgAfr5B9DtFAEtjo"
},
"labels": {
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd": "BAL Inheritance transaction",
"4c955222d31ed19318841b7f3dd0e95edd6198ebddc0dbea1c249c38c609aeac": "BAL Invalidate transaction",
"5833dd0e77cc9e22d981b92e71e5f5804deb5294dfa4dc666cfe4fa015afee8d": "BAL Inheritance transaction",
"5ca07e947509bea249d18591358c13a9c55e02b897390bab7ce0d2c6b2b394ec": "BAL Inheritance transaction",
@@ -161,6 +164,7 @@
"a45935c22968fe56dfdc329420336682cebfce0691ed764ab5891199ed91a158": "BAL Inheritance transaction",
"a526a6c1a74951df5bf5845f64dc97c3c9410935e2d05f6622a8f4ba31ec6811": "BAL Inheritance transaction",
"b45102630bd6f7b8774dff3ed39b393097e7eb6ae4714716f6e925497db4d6d9": "BAL Inheritance transaction",
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519": "BAL Inheritance transaction",
"e89b2329d4fe2b9349739b54720404f22d6b49ce94c297ce793f5e4e1f51672a": "BAL Invalidate transaction",
"f54f6c6a4bf6820b428ca5f6b7200545dacfd5e671e001c524888e502d007eaa": "BAL Invalidate transaction"
},
@@ -282,7 +286,7 @@
"0": "7cb49f910a6db5d95436943456293aefbcccf0bbcc78b1dfe0adec430997fe07"
}
},
"stored_height": 2249,
"stored_height": 2336,
"submarine_swaps": {},
"transactions": {
"4c955222d31ed19318841b7f3dd0e95edd6198ebddc0dbea1c249c38c609aeac": "0200000000010108d37b8e201da3203b47c7cc33fdf21a55b9cf776fdf5d1a028cd250e953d9d20000000000fdffffff01ef56e44130000000160014f0df9afc9fdd8ec20c46eb6af56d3c4934063343024730440220625a4a9944af68bd2b4c4146ae9db95f052725b8ca4fc8e8fca541d1c863d37a022004a705b8974e5fea3707acd0d8cc5f9a9c898a508fd0ae348b81043c4a86ad0b0121021e753044de0b2d3894751fae6e62c0627d379752809e522a5d0d4e3689e4ba9fa9080000",
@@ -393,6 +397,91 @@
},
"wallet_type": "standard",
"will": {
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd": {
"ANTICIPATED": false,
"BROADCASTED": false,
"CHECKED": true,
"CHECK_FAIL": false,
"COMPLETE": true,
"CONFIRMED": false,
"ERROR": false,
"EXPIRED": false,
"EXPORTED": false,
"IMPORTED": false,
"INVALIDATED": false,
"MEMPOOL": false,
"PUSHED": true,
"PUSH_FAIL": false,
"REPLACED": false,
"RESTORED": false,
"UPDATED": false,
"VALID": true,
"_id": "11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd",
"baltx_fees": 1,
"change": null,
"description": "w!ll3x3c\"http://localhost:9133\"1813204800\nmario2\naaaa\nlucia\nmario",
"heirs": {
"aaaa": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"34%",
1813204800,
69771996601
],
"lucia": [
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
"32%",
1813204800,
65667761507
],
"mario": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"35%",
1813204800,
71824114148
],
"mario2": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
40000,
1813204800,
40001,
40000
],
"w!ll3x3c\"http://localhost:9133\"1813204800": [
"bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
1000,
1813204800,
1000
]
},
"status": "New.Firmato.Pushed.Checked",
"time": 1785536273.0165532,
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402201ce57302608a734f3de1b0d3b0eca08efb78f0d886180e7adf4897bfe4b4a49302202120e6f4308cef294094fa347ae7369eb0b0bb9b1bb64c8f03f50b349f01d7e1012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7404f136c",
"willexecutor": {
"address": "bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
"balance": 90000,
"base_fee": 1000,
"broadcast_status": "Riuscito",
"chain": "regtest",
"count_win": 0,
"id": 66,
"info": "BAL devel willexecutor server",
"last_block": 0,
"last_update": 1785463888.38115,
"onion_url": null,
"points": 0,
"promo_code": null,
"selected": true,
"status": 200,
"tld": "localhost",
"txs": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402201ce57302608a734f3de1b0d3b0eca08efb78f0d886180e7adf4897bfe4b4a49302202120e6f4308cef294094fa347ae7369eb0b0bb9b1bb64c8f03f50b349f01d7e1012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7404f136c\n",
"txsids": [
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd"
],
"unconfirmed_balance": 0,
"url": "http://localhost:9133",
"version": "0.3.2"
}
},
"6ad1383bb5d358c4b7bcebbf009d642e7a78b923cf3b764b196904c68ffa116d": {
"ANTICIPATED": false,
"BROADCASTED": false,
@@ -493,10 +582,10 @@
"MEMPOOL": false,
"PUSHED": true,
"PUSH_FAIL": false,
"REPLACED": false,
"REPLACED": true,
"RESTORED": false,
"UPDATED": false,
"VALID": true,
"VALID": false,
"_id": "a45935c22968fe56dfdc329420336682cebfce0691ed764ab5891199ed91a158",
"baltx_fees": 1,
"change": null,
@@ -534,7 +623,7 @@
1000
]
},
"status": "New.Firmato.Pushed.Checked",
"status": "New.Firmato.Pushed.Checked.Replaced",
"time": 1785449918.3222272,
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402200b71f679baef994767c58cea234f2a2baa83cc029f64dae4ff6fe451aaffc369022051b2a70fd5c33fcc175d6dca4b052b4a7bc168c761434139560bee66ed965b18012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7c0cdd073",
"willexecutor": {
@@ -562,6 +651,91 @@
"url": "http://localhost:9133",
"version": "0.3.2"
}
},
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519": {
"ANTICIPATED": false,
"BROADCASTED": false,
"CHECKED": true,
"CHECK_FAIL": false,
"COMPLETE": true,
"CONFIRMED": false,
"ERROR": false,
"EXPIRED": false,
"EXPORTED": false,
"IMPORTED": false,
"INVALIDATED": false,
"MEMPOOL": false,
"PUSHED": true,
"PUSH_FAIL": false,
"REPLACED": true,
"RESTORED": false,
"UPDATED": false,
"VALID": false,
"_id": "dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519",
"baltx_fees": 1,
"change": null,
"description": "w!ll3x3c\"http://localhost:9133\"1817006400\nmario2\naaaa\nlucia\nmario",
"heirs": {
"aaaa": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"34%",
"1y",
69771996601
],
"lucia": [
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
"32%",
"1y",
65667761507
],
"mario": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"35%",
"1y",
71824114148
],
"mario2": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
40000,
"1y",
40001,
40000
],
"w!ll3x3c\"http://localhost:9133\"1817006400": [
"bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
1000,
1817006400,
1000
]
},
"status": "New.Firmato.Pushed.Checked.Replaced",
"time": 1785529939.6807153,
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402203e830a47f218826e2d1e4b708fdf4dc4a8826ac5c8a37a79fec5fbf1c6aa287002205df204067685f8874211ad092b0dad17a725f82a51e16f5c52db778135ecfbab012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b740514d6c",
"willexecutor": {
"address": "bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
"balance": 90000,
"base_fee": 1000,
"broadcast_status": "Riuscito",
"chain": "regtest",
"count_win": 0,
"id": 66,
"info": "BAL devel willexecutor server",
"last_block": 0,
"last_update": 1785463888.38115,
"onion_url": null,
"points": 0,
"promo_code": null,
"selected": true,
"status": 200,
"tld": "localhost",
"txs": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402203e830a47f218826e2d1e4b708fdf4dc4a8826ac5c8a37a79fec5fbf1c6aa287002205df204067685f8874211ad092b0dad17a725f82a51e16f5c52db778135ecfbab012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b740514d6c\n",
"txsids": [
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519"
],
"unconfirmed_balance": 0,
"url": "http://localhost:9133",
"version": "0.3.2"
}
}
},
"winpos-qt": [

View File

@@ -21,18 +21,21 @@ Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
"""
import sys
import os
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import (
WillItem, Will,
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
TxFeesChangedException, WillExpiredException,
HeirNotFoundException,
NoHeirsException,
NotCompleteWillException,
TxFeesChangedException,
Will,
WillExpiredException,
WillItem,
)
from bal.core.util import Util
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
_VALID_TX_HEX = (

View File

@@ -26,30 +26,28 @@ def main():
from PyQt6.QtWidgets import QApplication # noqa
_app = QApplication.instance() or QApplication([])
results = {}
# 1) Core modules import (these must be GUI-free).
bal = imp_core("bal", "core.plugin_base")
util = imp_core("util", "core.util")
heirs = imp_core("heirs", "core.heirs")
will = imp_core("will", "core.will")
we = imp_core("willexecutors", "core.willexecutors")
_we = imp_core("willexecutors", "core.willexecutors")
# 2) GUI module imports.
qt = imp_gui()
# 3) Behaviour checks (pure logic, must be identical across versions).
BalTimestamp = bal.BalTimestamp
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
bal_timestamp = bal.BalTimestamp
assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
Util = util.Util
assert Util.is_perc("50%") is True
assert Util.is_perc("100") is False
assert Util.text_to_hex("BAL") == "42414c"
assert Util.hex_to_text("42414c") == "BAL"
assert Util.int_locktime(days=1) == 86400
util_cls = util.Util
assert util_cls.is_perc("50%") is True
assert util_cls.is_perc("100") is False
assert util_cls.text_to_hex("BAL") == "42414c"
assert util_cls.hex_to_text("42414c") == "BAL"
assert util_cls.int_locktime(days=1) == 86400
# heirs constants must keep the same column layout (very delicate!)
assert heirs.HEIR_ADDRESS == 0

View File

@@ -27,19 +27,19 @@ Run:
tests/test_anticipate_manual_locktime.py -q
"""
import sys
import os
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest # noqa: E402
from bal.core.will import ( # noqa: E402
WillItem,
Will,
NotCompleteWillException,
Will,
WillExpiredException,
WillItem,
)
# A valid serialized tx (1 input + 1 output, version 2).

View File

@@ -22,13 +22,13 @@ whether a fix is needed. Run:
python3 -m pytest tests/test_anticipate_past_locktime.py -q
"""
import sys
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.util import Util, LOCKTIME_THRESHOLD
from bal.core.util import LOCKTIME_THRESHOLD, Util
# ---------------------------------------------------------------------------

View File

@@ -9,25 +9,34 @@ Run:
python3 tests/test_core_heirs.py
"""
import sys
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
HEIR_ADDRESS,
HEIR_AMOUNT,
HEIR_DUST_AMOUNT,
HEIR_LOCKTIME,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
create_op_return_script,
is_op_return_address, get_op_return_hex, validate_op_return_hex,
TRANSACTION_LABEL,
AliasNotFoundException,
NotAnAddress, AmountNotValid, LocktimeNotValid,
HeirExpiredException, HeirAmountIsDustException,
NoHeirsException, WillExecutorFeeException,
AmountNotValid,
BalanceTooLowException,
HeirAmountIsDustException,
Heirs,
LocktimeNotValid,
NoHeirsException,
NotAnAddress,
WillExecutorFeeException,
create_op_return_script,
get_op_return_hex,
is_op_return_address,
validate_op_return_hex,
)
# ------------------------------------------------------------------ #
# Constants
# ------------------------------------------------------------------ #
@@ -70,7 +79,7 @@ def test_op_return_empty():
def test_op_return_too_big():
try:
create_op_return_script("ab" * 81) # 81 bytes > max 80
assert False, "expected ValueError"
raise AssertionError("expected ValueError")
except ValueError:
pass
@@ -179,13 +188,13 @@ def test_validate_amount():
# Invalid
try:
Heirs.validate_amount("0.000000001")
assert False, "expected AmountNotValid"
raise AssertionError("expected AmountNotValid")
except AmountNotValid:
pass
try:
Heirs.validate_amount("-1")
assert False, "expected AmountNotValid"
raise AssertionError("expected AmountNotValid")
except AmountNotValid:
pass
@@ -209,7 +218,7 @@ def test_validate_locktime_expired():
past = int(time.time()) - 86400 # yesterday
try:
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
assert False, "expected LocktimeNotValid"
raise AssertionError("expected LocktimeNotValid")
except LocktimeNotValid:
pass
@@ -289,7 +298,7 @@ def test_validate_op_return_hex_valid():
def test_validate_op_return_hex_invalid():
try:
validate_op_return_hex("nothex!!")
assert False, "expected NotAnAddress"
raise AssertionError("expected NotAnAddress")
except NotAnAddress:
pass
@@ -297,7 +306,7 @@ def test_validate_op_return_hex_invalid():
def test_validate_op_return_hex_too_long():
try:
validate_op_return_hex("ab" * 81)
assert False, "expected NotAnAddress"
raise AssertionError("expected NotAnAddress")
except NotAnAddress:
pass
@@ -355,4 +364,4 @@ if __name__ == "__main__":
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print(f"[OK] All heirs tests passed")
print("[OK] All heirs tests passed")

View File

@@ -8,19 +8,20 @@ Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
"""
import sys
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
Heirs, create_op_return_script, reduce_outputs,
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
HEIR_AMOUNT,
Heirs,
create_op_return_script,
reduce_outputs,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Heirs db-dependent methods
# ------------------------------------------------------------------ #
@@ -188,7 +189,7 @@ def test_validate_address_invalid():
from bal.core.heirs import NotAnAddress
try:
Heirs.validate_address("bad")
assert False, "should have raised"
raise AssertionError("should have raised")
except NotAnAddress:
pass

View File

@@ -8,14 +8,15 @@ Run:
python3 tests/test_core_plugin_base.py
"""
import sys
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from datetime import datetime, date, timedelta
from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig
from datetime import date, datetime, timedelta
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
# ------------------------------------------------------------------ #
# BalTimestamp

View File

@@ -9,13 +9,14 @@ Run:
python3 tests/test_core_util.py
"""
import sys
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest
from bal.core.util import Util, LOCKTIME_THRESHOLD
from bal.core.util import Util
def test_locktime_to_str():
@@ -40,7 +41,7 @@ def test_str_to_locktime():
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid
# relative locktime, so it is NOT passed through unchanged.
with pytest.raises(Exception):
with pytest.raises(ValueError):
Util.str_to_locktime("144b")
# integer string -> int
@@ -347,44 +348,44 @@ def test_in_utxo():
def test_cmp_output():
class O:
class Obj:
def __init__(self, addr, val):
self.address = addr
self.value = val
assert Util.cmp_output(O("a", 100), O("a", 100)) is True
assert Util.cmp_output(O("a", 100), O("b", 100)) is False
assert Util.cmp_output(O("a", 100), O("a", 200)) is False
assert Util.cmp_output(Obj("a", 100), Obj("a", 100)) is True
assert Util.cmp_output(Obj("a", 100), Obj("b", 100)) is False
assert Util.cmp_output(Obj("a", 100), Obj("a", 200)) is False
def test_in_output():
class O:
class Obj:
def __init__(self, addr, val):
self.address = addr
self.value = val
outputs = [O("a", 100), O("b", 200)]
assert Util.in_output(O("a", 100), outputs) is True
assert Util.in_output(O("z", 999), outputs) is False
assert Util.in_output(O("a", 100), []) is False
outputs = [Obj("a", 100), Obj("b", 200)]
assert Util.in_output(Obj("a", 100), outputs) is True
assert Util.in_output(Obj("z", 999), outputs) is False
assert Util.in_output(Obj("a", 100), []) is False
def test_din_output():
class O:
class Obj:
def __init__(self, addr, val):
self.address = addr
self.value = val
outputs = [O("a", 100), O("b", 200)]
outputs = [Obj("a", 100), Obj("b", 200)]
# same amount AND same address
same_amt, same_addr = Util.din_output(O("a", 100), outputs)
same_amt, same_addr = Util.din_output(Obj("a", 100), outputs)
assert same_amt is True and same_addr is True
# same amount but different address
same_amt, same_addr = Util.din_output(O("c", 100), outputs)
same_amt, same_addr = Util.din_output(Obj("c", 100), outputs)
assert same_amt is True and same_addr is False
# different amount
same_amt, same_addr = Util.din_output(O("z", 999), outputs)
same_amt, same_addr = Util.din_output(Obj("z", 999), outputs)
assert same_amt is False and same_addr is False

View File

@@ -8,13 +8,13 @@ Run:
python3 tests/test_core_will.py
"""
import sys
import os
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import WillItem, Will
from bal.core.willexecutors import Willexecutors
from bal.core.will import Will, WillItem
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = (
@@ -332,11 +332,19 @@ def test_will_check_tx_height():
def test_exceptions():
from bal.core.will import (
WillException, WillExpiredException, NotCompleteWillException,
HeirChangeException, TxFeesChangedException, HeirNotFoundException,
WillexecutorChangeException, NoWillExecutorNotPresent,
WillExecutorNotPresent, NoHeirsException,
AmountException, PercAmountException, FixedAmountException,
AmountException,
FixedAmountException,
HeirChangeException,
HeirNotFoundException,
NoHeirsException,
NotCompleteWillException,
NoWillExecutorNotPresent,
PercAmountException,
TxFeesChangedException,
WillException,
WillexecutorChangeException,
WillExecutorNotPresent,
WillExpiredException,
WillPostponedException,
)
@@ -375,4 +383,4 @@ if __name__ == "__main__":
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print(f"[OK] All Will tests passed")
print("[OK] All Will tests passed")

View File

@@ -8,14 +8,28 @@ Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
"""
import sys
import os
from unittest.mock import MagicMock, patch, PropertyMock, call
import sys
from binascii import unhexlify
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import crypto
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.bitcoin import public_key_to_p2wpkh
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
Transaction,
TxOutpoint,
)
from bal.core.util import Util
from bal.core.will import Will, WillItem
from electrum.transaction import Transaction
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
@@ -170,6 +184,8 @@ def test_mempool_status_clears_valid():
def test_check_invalidated_invalidated():
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
# The funding is really consumed by a broadcast tx -> the will is dead.
wallet.adb.get_spender.return_value = "ab" * 32
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
@@ -185,6 +201,9 @@ def test_check_invalidated_invalidated():
def test_check_will():
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
# No broadcast tx spends the funding: the missing UTXO is only a local
# (history) artifact, so the will is not invalidated by it.
wallet.adb.get_spender.return_value = None
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
@@ -196,6 +215,141 @@ def test_check_will():
assert item.get_status("MEMPOOL") is True
# ------------------------------------------------------------------ #
# WillItem signature counts + PARTIALLY_SIGNED status
# ------------------------------------------------------------------ #
def _multisig_descriptor():
"""Return a 2-of-3 wsh multisig descriptor and its three pubkeys."""
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
return parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs))), pubs
def _make_multisig_ptx(nsigs, locktime=None):
"""A 2-of-3 wsh multisig PartialTransaction carrying ``nsigs`` signatures."""
desc, pubs = _multisig_descriptor()
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
txout = PartialTxOutput.from_address_and_value(addr, 50000)
ptx = PartialTransaction()
if locktime is not None:
ptx.locktime = locktime
ptx.add_inputs([txin])
ptx.add_outputs([txout])
return ptx
def _make_multisig_willitem(nsig):
"""A WillItem wrapping an unsigned 2-of-3 partial tx with ``nsig`` sigs.
The script descriptor is re-attached after the WillItem round-trips the tx
through serialization (PSBT serialization drops the descriptor but keeps
the signatures), mirroring the real load-from-wallet flow.
"""
w = {"tx": _make_multisig_ptx(nsig), "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}
item = WillItem(w, _id="mswill")
item.tx.inputs()[0].script_descriptor = _multisig_descriptor()[0]
return item
def test_willitem_sigs_fields_roundtrip():
item = _make_multisig_willitem(1)
assert item.sigs_required == 0
assert item.sigs_have == 0
item.sigs_required = 2
item.sigs_have = 1
item.set_status("PARTIALLY_SIGNED", True)
d = item.to_dict()
assert d["sigs_required"] == 2
assert d["sigs_have"] == 1
assert d["PARTIALLY_SIGNED"] is True
item2 = WillItem(d, _id="mswill")
assert item2.sigs_required == 2
assert item2.sigs_have == 1
assert item2.get_status("PARTIALLY_SIGNED") is True
def test_willitem_legacy_dict_defaults_sig_fields():
# A will saved before the signature-tracking feature has no sig fields:
# they must default to 0 and the flag to False.
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}, _id="legacy")
assert item.sigs_required == 0
assert item.sigs_have == 0
assert item.get_status("PARTIALLY_SIGNED") is False
def test_willitem_partial_signed_keeps_valid():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
assert item.get_status("PARTIALLY_SIGNED") is True
assert item.get_status("VALID") is True
assert "Partially Signed" in item.status
def test_willitem_complete_clears_partially_signed():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
item.set_status("COMPLETE", True)
assert item.get_status("COMPLETE") is True
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_partial():
item = _make_multisig_willitem(1)
Will.check_signatures({"mswill": item})
assert item.sigs_have == 1
assert item.sigs_required == 2
assert item.get_status("PARTIALLY_SIGNED") is True
assert item.get_status("VALID") is True
def test_check_signatures_unsigned_not_partial():
item = _make_multisig_willitem(0)
Will.check_signatures({"mswill": item})
assert item.sigs_have == 0
assert item.sigs_required == 2
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_fully_signed_clears_flag():
item = _make_multisig_willitem(2)
item.set_status("PARTIALLY_SIGNED", True)
Will.check_signatures({"mswill": item})
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_complete_item_clears_flag():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
item.set_status("COMPLETE", True)
Will.check_signatures({"mswill": item})
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_single_sig_required():
# A single-signature (P2WPKH) will needs exactly 1 signature: 0 present is
# "New", not "partially signed".
pub = crypto.privkey_to_pubkey(bytes([7] * 32)).hex()
item = _make_multisig_willitem(0)
item.tx.inputs()[0].script_descriptor = parse_descriptor("wpkh({})".format(pub))
Will.check_signatures({"mswill": item})
assert item.sigs_required == 1
assert item.sigs_have == 0
assert item.get_status("PARTIALLY_SIGNED") is False
# ------------------------------------------------------------------ #
# WillItem.__init__ with wallet
# ------------------------------------------------------------------ #
@@ -217,6 +371,472 @@ def test_willitem_init_without_wallet():
assert item is not None
# ------------------------------------------------------------------ #
# Will.save_valid_transactions_to_history (history persistence)
# ------------------------------------------------------------------ #
class FakeTxMinedStatus:
def __init__(self, height):
self._height = height
def height(self):
return self._height
class FakeTxInfo:
def __init__(self, height):
self.tx_mined_status = FakeTxMinedStatus(height)
class FakeWallet:
"""Minimal stand-in for an Electrum wallet used by history persistence."""
def __init__(self, stored_txs=None, spenders=None, heights=None, outputs=None,
addresses=None):
self.adb = FakeADB(
stored_txs or {},
spenders=spenders,
heights=heights,
outputs=outputs,
)
self.db = self.adb.db
self.labels = {}
self.save_db_called = 0
self.addresses = list(addresses or [])
def set_label(self, txid, label):
if label is None:
self.labels.pop(txid, None)
else:
self.labels[txid] = label
def get_all_labels(self):
return dict(self.labels)
def save_db(self):
self.save_db_called += 1
def get_addresses(self):
return self.addresses
def get_label_for_txid(self, txid):
return self.labels.get(txid, "")
def get_tx_info(self, tx):
height = self.adb.heights.get(tx.txid(), TX_HEIGHT_LOCAL)
return FakeTxInfo(height)
def get_utxos(self):
utxos = []
for outs in self.adb.outputs.values():
for utxo in outs.values():
if utxo.spent_height is None:
utxos.append(utxo)
return utxos
class FakeADB:
def __init__(self, stored_txs, spenders=None, heights=None, outputs=None):
self.db = FakeDB(stored_txs)
self.added = []
self.removed = []
self.spenders = dict(spenders or {})
self.heights = dict(heights or {})
self.outputs = dict(outputs or {})
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
self.added.append((tx, allow_unrelated))
return True
def remove_transaction(self, txid):
self.removed.append(txid)
def get_spender(self, outpoint):
txid = self.spenders.get(outpoint)
if txid is None:
return None
height = self.heights.get(txid, TX_HEIGHT_LOCAL)
if height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return None
return txid
def get_tx_height(self, txid):
return FakeTxMinedStatus(self.heights.get(txid, TX_HEIGHT_LOCAL))
def get_addr_outputs(self, addr):
return self.outputs.get(addr, {})
class FakeDB:
def __init__(self, stored_txs):
self.stored = dict(stored_txs)
def get_transaction(self, txid):
return self.stored.get(txid)
def _make_simple_willitem(tx, valid=True, we_url=None):
"""A WillItem wrapping *tx* with an optional VALID status and executor."""
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": {"url": we_url} if we_url else None, "status": "",
"description": "", "time": 0, "change": "", "baltx_fees": 100,
"VALID": valid}
return WillItem(w, _id="wid")
def _exec_label(template, url):
return template.replace("{willexecutor}", url)
def test_save_incomplete_valid_tx_to_history_adds_and_labels():
# A still-unsigned / partially-signed ("New") partial tx is stored in the
# local history, tagged with the decoded label.
tx = _make_multisig_ptx(1)
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
wallet = FakeWallet()
Will.save_valid_transactions_to_history(
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert [t.txid() for t, _ in wallet.adb.added] == [tx.txid()]
txid = tx.txid()
assert wallet.labels[txid] == "BitcoinAfterLife inheritance transaction - https://we.example"
assert wallet.save_db_called == 1
def test_save_skips_complete_and_invalid_items():
# A fully-signed ("Complete") tx must NOT be stored: it is removed from the
# local history instead. Invalid items are never touched.
complete = _make_multisig_willitem(2)
complete.set_status("VALID", True)
invalid = _make_simple_willitem(Transaction(_VALID_TX_HEX), valid=False)
new = _make_simple_willitem(_make_multisig_ptx(1), valid=True)
wallet = FakeWallet()
Will.save_valid_transactions_to_history(
{"complete": complete, "invalid": invalid, "new": new},
wallet,
"BitcoinAfterLife inheritance transaction - {willexecutor}",
)
assert [t.txid() for t, _ in wallet.adb.added] == [new.tx.txid()]
assert len(wallet.labels) == 1
assert wallet.labels[new.tx.txid()] == (
"BitcoinAfterLife inheritance transaction - "
)
def test_save_combines_sigs_when_stored_partial():
# The tx to save is already present in the wallet as an incomplete partial
# PSBT: the signatures are combined into it instead of blindly overwriting.
# (Exercised on raw PartialTransactions; through WillItem a complete tx
# round-trips to a plain Transaction, which overwrites instead - see
# test_save_complete_tx_overwrites_stored_partial.)
our = _make_multisig_ptx(2)
stored_partial = _make_multisig_ptx(1)
wallet = FakeWallet(stored_txs={our.txid(): stored_partial})
Will._add_transaction_to_history(wallet, our, our.txid())
assert len(wallet.adb.added) == 1
saved, _ = wallet.adb.added[0]
# The combine path was taken (the stored partial was re-added, not our tx).
assert saved is stored_partial
assert saved.is_complete()
def test_save_removes_complete_item_from_history():
# A fully-signed item is removed from the local history: its matching
# entry (exact label) is deleted, and it is never re-added.
our = _make_multisig_ptx(2)
txid = our.txid()
label = "BitcoinAfterLife inheritance transaction - https://we.example"
item = _make_simple_willitem(our, valid=True, we_url="https://we.example")
wallet = FakeWallet(stored_txs={txid: our})
wallet.labels[txid] = label
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
assert wallet.adb.added == []
assert txid in wallet.adb.removed
assert txid not in wallet.labels
def test_save_cleanup_removes_stale_exact_label():
tx = _make_multisig_ptx(1)
txid = tx.txid()
stale_txid = "ab" * 32
other_txid = "cd" * 32
label = "BitcoinAfterLife inheritance transaction - https://we.example"
wallet = FakeWallet()
# Pre-existing wallet labels: one current, one stale (same label), one with
# a different executor URL that must be kept.
wallet.labels[txid] = label
wallet.labels[stale_txid] = label
wallet.labels[other_txid] = "BitcoinAfterLife inheritance transaction - https://other.example"
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
assert stale_txid in wallet.adb.removed
assert other_txid not in wallet.adb.removed
assert txid not in wallet.adb.removed
# The stale tx's label is dropped with it.
assert stale_txid not in wallet.labels
assert other_txid in wallet.labels
assert wallet.labels[txid] == label
def test_save_no_wallet_or_no_adb_is_noop():
tx = Transaction(_VALID_TX_HEX)
item = _make_simple_willitem(tx, valid=True)
Will.save_valid_transactions_to_history({"wid": item}, None, "LBL")
Will.save_valid_transactions_to_history({"wid": item}, object(), "LBL")
def test_save_never_raises_on_adb_failure():
tx = Transaction(_VALID_TX_HEX)
item = _make_simple_willitem(tx, valid=True)
class BoomWallet(FakeWallet):
class BoomADB:
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
raise RuntimeError("boom")
def remove_transaction(self, txid):
raise RuntimeError("boom")
def __init__(self):
self.adb = self.BoomADB()
self.labels = {}
def get_all_labels(self):
return {"stale": "BitcoinAfterLife inheritance transaction - "}
wallet = BoomWallet()
Will.save_valid_transactions_to_history(
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
# No exception propagates; a fresh fake works afterwards.
assert True
def test_check_will_does_not_save_to_history():
# History persistence is no longer triggered from check_will: it runs after
# the will is signed (see the GUI hooks). check_will must not touch it.
with patch.object(Will, "save_valid_transactions_to_history") as save_mock:
Will.check_will({}, [], None, 9999999999)
save_mock.assert_not_called()
def test_is_will_valid_calls_check_will_without_history_label():
with patch.object(Will, "check_will") as cw_mock:
Will.is_will_valid({}, 9999999999, 100, [])
assert len(cw_mock.call_args[0]) == 4
assert cw_mock.call_args[0] == ({}, [], False, 9999999999)
# ------------------------------------------------------------------ #
# Signature absorption + status fixes (local-history will tx)
# ------------------------------------------------------------------ #
def _make_willitem_keyed_by_txid(tx, valid=True, we_url=None):
"""A WillItem whose ``_id`` equals its txid (as in a real built will).
The script descriptor is re-attached after the WillItem round-trips the tx
through serialization, mirroring the real load-from-wallet flow.
"""
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": {"url": we_url} if we_url else None, "status": "",
"description": "", "time": 0, "change": "", "baltx_fees": 100,
"VALID": valid}
item = WillItem(w, _id=tx.txid())
if isinstance(tx, PartialTransaction) and tx.inputs():
desc = getattr(tx.inputs()[0], "script_descriptor", None)
if desc is not None and isinstance(item.tx, PartialTransaction):
item.tx.inputs()[0].script_descriptor = desc
return item
def _make_local_wallet(tx, stored, funding):
"""A FakeWallet where *tx* is stored locally and consumes *funding*."""
return FakeWallet(
stored_txs={tx.txid(): stored},
spenders={funding: tx.txid()},
heights={tx.txid(): TX_HEIGHT_LOCAL},
)
def test_absorb_history_signatures_merges_and_completes():
# The wallet's stored local copy of the will tx carries more signatures
# than the in-memory item: they are merged in and the item becomes COMPLETE.
item = _make_multisig_willitem(1)
stored = _make_multisig_ptx(2)
assert stored.txid() == item.tx.txid()
wallet = FakeWallet(stored_txs={item._id: stored})
Will._absorb_history_signatures({item._id: item}, wallet)
assert item.tx.is_complete() is True
assert item.get_status("COMPLETE") is True
assert item.get_status("VALID") is True
def test_absorb_history_signatures_noop_without_stored_copy():
# Nothing stored in the wallet: the in-memory item is left untouched.
item = _make_multisig_willitem(1)
wallet = FakeWallet()
Will._absorb_history_signatures({item._id: item}, wallet)
assert item.tx.is_complete() is False
assert item.get_status("COMPLETE") is False
def test_check_invalidated_keeps_valid_on_local_spend():
# The funding is missing from the wallet's UTXOs only because the will tx
# itself was saved into the local history: a wallet-local spender must not
# invalidate the will.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, tx, funding)
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is False
assert item.get_status("VALID") is True
def test_check_invalidated_invalidates_on_real_spend():
# The funding is consumed by a broadcast transaction: the will is dead.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
ext_spender = "ab" * 32
wallet = FakeWallet(
spenders={funding: ext_spender},
heights={ext_spender: 100},
)
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_search_rai_local_artifact_keeps_valid():
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, tx, funding)
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
assert item.get_status("VALID") is True
assert item.get_status("INVALIDATED") is False
assert item.get_status("CONFIRMED") is False
def test_search_rai_confirmed_on_broadcast_spender():
# The will tx is broadcast/confirmed: its own (real) spender marks it
# CONFIRMED, not INVALIDATED.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = FakeWallet(
stored_txs={tx.txid(): tx},
spenders={funding: tx.txid()},
heights={tx.txid(): 100},
)
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
assert item.get_status("CONFIRMED") is True
assert item.get_status("INVALIDATED") is False
def test_check_will_merges_history_sigs_and_stays_valid():
# End-to-end: a will tx stored in the local history gained a signature.
# check_will must absorb it, not invalidate the will for the local spend.
now = 1700000000
locktime = 2000000000
tx = _make_multisig_ptx(1, locktime)
stored = _make_multisig_ptx(2, locktime)
assert stored.txid() == tx.txid()
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, stored, funding)
Will.check_will(will, [], wallet, now)
assert item.get_status("COMPLETE") is True
assert item.get_status("VALID") is True
assert item.get_status("INVALIDATED") is False
# ------------------------------------------------------------------ #
# Util.get_available_utxos (UTXO-view restoration)
# ------------------------------------------------------------------ #
def _make_utxo(prevout_hex="22", idx=0, value=100000,
spent_txid=None, spent_height=None):
txin = PartialTxInput(
prevout=TxOutpoint(bytes.fromhex(prevout_hex) * 32, idx), script_sig=b""
)
txin._trusted_value_sats = value
txin.spent_txid = spent_txid
txin.spent_height = spent_height
return txin
_HISTORY_TEMPLATE = "BitcoinAfterLife inheritance transaction - {willexecutor}"
_HISTORY_LABEL = _HISTORY_TEMPLATE.replace("{willexecutor}", "https://we.example")
def _wallet_with_local_spend(locktime):
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=TX_HEIGHT_LOCAL)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=locktime)},
heights={spender: TX_HEIGHT_LOCAL},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
return wallet, utxo
def test_get_available_utxos_restores_future_bal_local_spend():
# A later-locktime BAL history tx locally spent the coin: it is restored.
wallet, utxo = _wallet_with_local_spend(locktime=2000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert [u.prevout.to_str() for u in result] == [utxo.prevout.to_str()]
def test_get_available_utxos_does_not_restore_unlabeled_spend():
wallet, utxo = _wallet_with_local_spend(locktime=2000)
wallet.labels["ab" * 32] = "some other label"
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_does_not_restore_not_later_locktime():
# The stored spender's locktime equals the will's locktime (same will):
# its spend is NOT ignored.
wallet, utxo = _wallet_with_local_spend(locktime=1000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_does_not_restore_confirmed_spend():
# A broadcast (confirmed) spender is never ignored.
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=100)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
heights={spender: 100},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_none_locktime_is_raw_view():
# No reference locktime: the raw wallet.get_utxos() view is returned, so a
# locally-spent coin stays hidden.
wallet, utxo = _wallet_with_local_spend(locktime=2000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, None)
assert result == []
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #

View File

@@ -22,15 +22,14 @@ import os
import sys
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import Will, WillItem
# Patch Transaction.add_info_from_wallet so WillItem can parse the tx hex
# without a live Electrum wallet connection.
from electrum.transaction import Transaction
from bal.core.will import Will, WillItem
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()

View File

@@ -28,7 +28,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Mocks
# ------------------------------------------------------------------ #

View File

@@ -27,7 +27,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig
# ------------------------------------------------------------------ #
# Mocks
# ------------------------------------------------------------------ #
@@ -65,6 +64,47 @@ def test_editable_dates_can_be_enabled():
assert BalConfig(cfg, "bal_editable_dates", False).get() is True
# ------------------------------------------------------------------ #
# History persistence settings (SAVE_HISTORY / HISTORY_LABEL)
# ------------------------------------------------------------------ #
def test_save_history_defaults_on():
"""History persistence is opt-out: the flag defaults to ON."""
cfg = FakeConfig()
save_history = BalConfig(cfg, "bal_save_history", True)
assert save_history.get() is True
def test_save_history_can_be_disabled_and_read_back():
cfg = FakeConfig()
save_history = BalConfig(cfg, "bal_save_history", True)
save_history.set(False)
assert BalConfig(cfg, "bal_save_history", True).get() is False
def test_history_label_default_template():
"""The default label contains the {willexecutor} variable."""
cfg = FakeConfig()
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert history_label.get() == (
"BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert "{willexecutor}" in history_label.get()
def test_history_label_can_be_changed_and_read_back():
cfg = FakeConfig()
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
history_label.set("My custom label for {willexecutor}")
assert BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
).get() == "My custom label for {willexecutor}"
# ------------------------------------------------------------------ #
# C4b - Reset to defaults
# ------------------------------------------------------------------ #
@@ -83,9 +123,10 @@ def _reset_to_defaults(configs):
def test_reset_restores_all_dialog_settings():
"""C4b: Reset restores every dialog setting to its factory default.
The dialog exposes seven settings: the original six plus the Group C
"Editable dates" checkbox, which the Reset button must also restore (this
was a follow-up fix after the first test round).
The dialog exposes nine settings: the original six, the Group C
"Editable dates" checkbox, and the Group H "Save inheritance transactions
in history" checkbox + "History label" field, which the Reset button must
also restore.
"""
cfg = FakeConfig()
@@ -103,6 +144,10 @@ def test_reset_restores_all_dialog_settings():
"bal_event_description",
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
)
save_history = BalConfig(cfg, "bal_save_history", True)
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
settings = [
hide_replaced,
hide_invalidated,
@@ -111,6 +156,8 @@ def test_reset_restores_all_dialog_settings():
calendar_app,
event_summary,
event_description,
save_history,
history_label,
]
# Mutate every setting away from its default.
@@ -121,11 +168,15 @@ def test_reset_restores_all_dialog_settings():
calendar_app.set("/custom/app")
event_summary.set("custom summary")
event_description.set("custom description")
save_history.set(False)
history_label.set("custom label")
# Sanity: the values really changed.
assert hide_replaced.get() is False
assert editable_dates.get() is True
assert calendar_app.get() == "/custom/app"
assert save_history.get() is False
assert history_label.get() == "custom label"
# Reset and verify each one is back to its declared default.
_reset_to_defaults(settings)
@@ -133,6 +184,11 @@ def test_reset_restores_all_dialog_settings():
assert s.get() == s.default
# In particular the "Editable dates" flag is back OFF.
assert editable_dates.get() is False
# And the history persistence flag is back ON with the default template.
assert save_history.get() is True
assert history_label.get() == (
"BitcoinAfterLife inheritance transaction - {willexecutor}"
)
def test_reset_does_not_touch_unrelated_settings():

View File

@@ -27,7 +27,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig
from bal.gui.qt.widgets import compute_reminder_offsets
# ------------------------------------------------------------------ #
# Mocks
# ------------------------------------------------------------------ #

View File

@@ -25,7 +25,6 @@ import copy
import json
import os
import sys
import warnings
import pytest
@@ -42,15 +41,12 @@ from electrum import bitcoin
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
TxOutpoint,
)
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.will import Will, WillItem
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Load karen7 wallet data
@@ -166,7 +162,7 @@ def _build_utxo_value_map(data):
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
for _, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
@@ -188,7 +184,7 @@ def _populate_input_values(will, utxo_value_map):
This is the equivalent of what ``add_info_from_wallet`` does in the
real flow: looking up the UTXO value and attaching it to the input.
"""
for wid, wi in will.items():
for _, wi in will.items():
for txin in wi.tx.inputs():
prevout_str = txin.prevout.to_str()
if txin._trusted_value_sats is None and prevout_str in utxo_value_map:

View File

@@ -36,13 +36,12 @@ import pytest
# below the repo root that contains the ``bal`` package).
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import WillItem, Will, HeirNotFoundException
from bal.core.heirs import Heirs
from bal.core.will import HeirNotFoundException, Will, WillItem
from bal.core.willexecutors import Willexecutors
from bal.gui.qt.calendar import BalCalendar
from bal.gui.qt.widgets import compute_reminder_offsets
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2). Reused from test_core_will.py so WillItem can parse a real tx.
_VALID_TX_HEX = (
@@ -550,7 +549,7 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
for _, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():

View File

@@ -16,8 +16,9 @@ Run:
python3 tests/test_group_f_heir_change_rebuild.py
"""
import sys
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import Will

View File

@@ -24,8 +24,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.widgets import (BASIC_REMINDER_OFFSETS,
basic_reminder_offsets)
from bal.gui.qt.widgets import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
def test_basic_offsets_all_future():

View File

@@ -32,7 +32,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS

View File

@@ -10,14 +10,12 @@ Run:
import os
import sys
import tempfile
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.calendar import BalCalendar
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #

View File

@@ -8,12 +8,13 @@ Run:
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel
# Import the module itself, not via "from .common import *"
import bal.gui.qt.common as C
import bal.gui.qt.common as common
_app = QApplication.instance() or QApplication(sys.argv)
@@ -23,18 +24,18 @@ _app = QApplication.instance() or QApplication(sys.argv)
# ------------------------------------------------------------------ #
def test_shown_cv_default():
cv = C.shown_cv(True)
cv = common.shown_cv(True)
assert cv.get() is True
def test_shown_cv_set():
cv = C.shown_cv(True)
cv = common.shown_cv(True)
cv.set(False)
assert cv.get() is False
def test_shown_cv_roundtrip():
cv = C.shown_cv(False)
cv = common.shown_cv(False)
assert cv.get() is False
cv.set(True)
assert cv.get() is True
@@ -47,19 +48,19 @@ def test_shown_cv_roundtrip():
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = C.CheckAliveError(1000000)
err = common.CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
def test_check_alive_error_str():
err = C.CheckAliveError(1000000)
err = common.CheckAliveError(1000000)
s = str(err)
assert "Check alive expired" in s
assert "1970" in s
def test_check_alive_error_subclass():
assert issubclass(C.CheckAliveError, Exception)
assert issubclass(common.CheckAliveError, Exception)
# ------------------------------------------------------------------ #
@@ -68,17 +69,15 @@ def test_check_alive_error_subclass():
def test_add_widget():
grid = QGridLayout()
parent = QWidget()
label = QLabel("test")
C.add_widget(grid, "Label", label, 0, "Help text")
common.add_widget(grid, "Label", label, 0, "Help text")
assert grid.count() == 3 # label + widget + help button
def test_add_widget_multiple_rows():
grid = QGridLayout()
parent = QWidget()
C.add_widget(grid, "A", QLabel("a"), 0, "help_a")
C.add_widget(grid, "B", QLabel("b"), 1, "help_b")
common.add_widget(grid, "A", QLabel("a"), 0, "help_a")
common.add_widget(grid, "B", QLabel("b"), 1, "help_b")
assert grid.count() == 6
@@ -87,7 +86,7 @@ def test_add_widget_multiple_rows():
# ------------------------------------------------------------------ #
def test_log_error_no_window():
C.log_error((Exception, Exception("test"), None))
common.log_error((Exception, Exception("test"), None))
# ------------------------------------------------------------------ #

View File

@@ -0,0 +1,216 @@
"""
Tests for the history-save hook in ``BalWindow``.
Verifies that every successful prepare (including the "Prepare" menu action)
persists the freshly prepared transactions into the wallet's local history,
while abort paths (which return ``None``) skip the save. Also verifies that
after any local-history change (saving or removing will transactions) the
wallet tabs are re-rendered through ``update_tabs`` (all tabs) plus
``update_status`` (status-bar balance), and that the rebuild path uses the same
full refresh.
Run:
source electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_prepare_will_history.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import Mock, patch
import bal.gui.qt.window as win_mod
from bal.core.util import Util
from bal.core.will import NotCompleteWillException, Will
from bal.gui.qt.window import BalWindow
# ------------------------------------------------------------------ #
# prepare_will -> _save_will_to_history
# ------------------------------------------------------------------ #
def test_prepare_will_saves_to_history_on_success():
win = object.__new__(BalWindow)
will = {"wid": object()}
with (
patch.object(BalWindow, "build_inheritance_transaction", return_value=will)
as build_mock,
patch.object(BalWindow, "_save_will_to_history") as save_mock,
):
result = BalWindow.prepare_will(win)
assert result is will
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
save_mock.assert_called_once_with()
def test_prepare_will_skips_save_when_build_aborted():
win = object.__new__(BalWindow)
with (
patch.object(BalWindow, "build_inheritance_transaction", return_value=None)
as build_mock,
patch.object(BalWindow, "_save_will_to_history") as save_mock,
):
result = BalWindow.prepare_will(win)
assert result is None
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
save_mock.assert_not_called()
# ------------------------------------------------------------------ #
# history refresh: _save_will_to_history -> update_tabs + update_status
# ------------------------------------------------------------------ #
class _Cfg:
def __init__(self, value):
self._value = value
def get(self):
return self._value
class _CfgBag:
def __init__(self, **kwargs):
for name, value in kwargs.items():
setattr(self, name, value)
class _Wallet:
def dust_threshold(self):
return 546
class _FakeWindow:
def __init__(self, *, with_update_tabs=True):
self.show_message = Mock()
self.update_status = Mock()
self.history_list = Mock()
self.history_list.update = Mock()
if with_update_tabs:
self.update_tabs = Mock()
class _FakeQTimer:
calls = []
@classmethod
def singleShot(cls, delay, callable_):
cls.calls.append((delay, callable_))
def _make_save_window(save_enabled):
win = object.__new__(BalWindow)
win.bal_plugin = _CfgBag(
SAVE_HISTORY=_Cfg(save_enabled), HISTORY_LABEL=_Cfg("LBL")
)
win.willitems = {"wid": object()}
win.wallet = object()
return win
def test_save_will_to_history_schedules_refresh_when_enabled():
win = _make_save_window(save_enabled=True)
with (
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
save_mock.assert_called_once_with(win.willitems, win.wallet, "LBL")
schedule_mock.assert_called_once_with()
def test_save_will_to_history_skips_save_and_refresh_when_disabled():
win = _make_save_window(save_enabled=False)
with (
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
save_mock.assert_not_called()
schedule_mock.assert_not_called()
def test_save_will_to_history_schedules_refresh_even_on_error():
win = _make_save_window(save_enabled=True)
with (
patch.object(
Will,
"save_valid_transactions_to_history",
side_effect=RuntimeError("boom"),
),
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
schedule_mock.assert_called_once_with()
def test_schedule_history_refresh_marshals_to_gui_thread():
win = object.__new__(BalWindow)
with patch.object(win_mod, "QTimer", _FakeQTimer):
_FakeQTimer.calls.clear()
BalWindow._schedule_history_refresh(win)
assert _FakeQTimer.calls == [(0, win._refresh_after_history_save)]
def test_refresh_after_history_save_calls_update_tabs_and_status():
win = object.__new__(BalWindow)
win.window = _FakeWindow(with_update_tabs=True)
BalWindow._refresh_after_history_save(win)
win.window.update_tabs.assert_called_once_with()
win.window.update_status.assert_called_once_with()
win.window.history_list.update.assert_not_called()
def test_refresh_after_history_save_falls_back_to_history_list():
win = object.__new__(BalWindow)
win.window = _FakeWindow(with_update_tabs=False)
BalWindow._refresh_after_history_save(win)
win.window.history_list.update.assert_called_once_with()
win.window.update_status.assert_called_once_with()
def test_rebuild_path_schedules_full_refresh():
win = object.__new__(BalWindow)
win.disable_plugin = False
win.heirs = {"h": object()}
win.willexecutors = {}
win.no_willexecutor = True
win.willitems = {}
win.will = {}
win.date_to_check = 1_800_000_000
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
win.bal_plugin = _CfgBag(
MAX_WILLEXECUTOR_FEE=_Cfg(1),
SAVE_HISTORY=_Cfg(True),
HISTORY_LABEL=_Cfg("LBL"),
)
win.window = _FakeWindow()
win.window.wallet = _Wallet()
with (
patch.object(Util, "get_available_utxos", return_value=[]),
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
patch.object(Will, "get_min_locktime", return_value=0),
patch.object(Will, "check_amounts"),
patch.object(BalWindow, "init_class_variables"),
patch.object(BalWindow, "build_will"),
patch.object(
BalWindow,
"check_will",
side_effect=[NotCompleteWillException(), None],
),
patch.object(BalWindow, "update_all"),
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow.build_inheritance_transaction(win)
schedule_mock.assert_called_once_with()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All prepare-will history tests passed")

View File

@@ -8,16 +8,19 @@ Run:
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.theme import status_color
from bal.gui.qt.theme import signature_suffix, status_color
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
class FakeWillItem:
sigs_required = 0
sigs_have = 0
def __init__(self, **status_flags):
self._status = dict(status_flags)
def get_status(self, name):
@@ -97,6 +100,54 @@ def test_color_check_fail_overrides_push_fail():
assert status_color(item) == "#e83845"
# ------------------------------------------------------------------ #
# PARTIALLY_SIGNED
# ------------------------------------------------------------------ #
def test_color_partially_signed():
assert status_color(FakeWillItem(PARTIALLY_SIGNED=True)) == "#ffb347"
def test_color_partially_signed_overridden_by_higher_priority():
item = FakeWillItem(PARTIALLY_SIGNED=True, PUSHED=True)
assert status_color(item) == "#73f3c8"
# ------------------------------------------------------------------ #
# signature_suffix
# ------------------------------------------------------------------ #
def test_signature_suffix_partial():
item = FakeWillItem(PARTIALLY_SIGNED=True)
item.sigs_required = 2
item.sigs_have = 1
assert signature_suffix(item) == " (1/2)"
def test_signature_suffix_new():
item = FakeWillItem()
item.sigs_required = 2
item.sigs_have = 0
assert signature_suffix(item) == " (0/2)"
def test_signature_suffix_complete_empty():
item = FakeWillItem(COMPLETE=True)
item.sigs_required = 2
item.sigs_have = 1
assert signature_suffix(item) == ""
def test_signature_suffix_unknown_required_empty():
item = FakeWillItem()
item.sigs_have = 1
assert signature_suffix(item) == ""
def test_signature_suffix_missing_fields_empty():
assert signature_suffix(FakeWillItem()) == ""
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #

View File

@@ -13,13 +13,11 @@ Run:
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QWidget
from electrum.util import DECIMAL_POINT, decimal_point_to_base_unit_name
_app = QApplication.instance() or QApplication(sys.argv)

View File

@@ -8,13 +8,18 @@ Run:
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
from bal.gui.qt.window_utils import (
bring_to_front, show_modal, show_on_top, stop_thread, top_level_of,
bring_to_front,
show_modal,
show_on_top,
stop_thread,
top_level_of,
)
_app = QApplication.instance() or QApplication(sys.argv)

View File

@@ -0,0 +1,559 @@
"""
Tests for the "Import" (read-only will preview) flow.
Covers:
- ``BalWindow._load_will_file`` round-trip (file -> WillItems).
- ``import_will_into_details`` normalization + IMPORTED status.
- ``BalWindow.sign_transactions`` operating ONLY on the passed (imported)
will, never on the live wallet state.
- ``WillWidget`` honouring an explicit ``will`` argument.
- ``WillDetailDialog`` external-will mode (threshold + isolated buttons).
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_import_will_details.py
"""
import sys
import tempfile
from types import MethodType, SimpleNamespace
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QWidget
from bal.core.will import Will, WillItem
from bal.gui.qt import window as window_mod
_app = QApplication.instance() or QApplication(sys.argv)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
def _make_willitem_dict(**overrides):
"""Return a minimal dict that can construct a WillItem."""
d = {
"tx": _VALID_TX_HEX,
"heirs": {},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
}
d.update(overrides)
return d
def _make_willitems(n, prefix="w"):
"""Create ``n`` WillItems with distinct txids/locktimes."""
willitems = {}
for i in range(n):
wid = f"{prefix}{i}"
wi = WillItem(_make_willitem_dict())
wi.tx.locktime = 1000 + i
wi._id = wid
willitems[wid] = wi
return willitems
# ------------------------------------------------------------------ #
# _load_will_file round-trip
# ------------------------------------------------------------------ #
def test_load_will_file_roundtrip():
from bal.gui.qt.common import write_json_file
src = _make_willitems(2)
data = {wid: wi.to_dict() for wid, wi in src.items()}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as f:
path = f.name
write_json_file(path, data)
try:
loaded = window_mod.BalWindow._load_will_file(None, path)
assert set(loaded) == {"w0", "w1"}
for wid, wi in loaded.items():
assert isinstance(wi, WillItem)
assert wi.heirs == {}
assert wi._id == wid
assert wi.tx is not None
finally:
import os
os.unlink(path)
# ------------------------------------------------------------------ #
# import_will_into_details normalization + IMPORTED status
# ------------------------------------------------------------------ #
def test_imported_will_is_normalized_and_marked_imported():
willitems = _make_willitems(1)
Will.normalize_will(willitems, None)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
assert all(wi.get_status("IMPORTED") for wi in willitems.values())
assert all(wi.get_status("VALID") for wi in willitems.values())
# ------------------------------------------------------------------ #
# sign_transactions operates only on the passed (imported) will
# ------------------------------------------------------------------ #
def test_sign_transactions_external_only():
class FakeWallet:
def sign_transaction(self, tx, password, ignore_warnings=False):
# No-op: never marks the tx complete.
return None
live = _make_willitems(1, prefix="live")
wid_live = next(iter(live))
imported = _make_willitems(2, prefix="imp")
fake = SimpleNamespace(
willitems=live,
wallet=FakeWallet(),
waiting_dialog=SimpleNamespace(update=lambda msg: None),
)
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
assert result is not None
assert set(result) == set(imported)
assert wid_live not in result
# The live will must be completely untouched by the external sign run.
assert live[wid_live].get_status("COMPLETE") is False
# ------------------------------------------------------------------ #
# WillWidget honours an explicit ``will`` argument
# ------------------------------------------------------------------ #
def test_will_widget_explicit_will():
from bal.gui.qt.widgets import WillWidget
live = _make_willitems(1, prefix="live")
imported = _make_willitems(2, prefix="imp")
fake_parent = SimpleNamespace(
decimal_point=8,
base_unit_name="BTC",
bal_window=SimpleNamespace(
willitems=live,
bal_plugin=SimpleNamespace(
_hide_replaced=False, _hide_invalidated=False
),
show_transaction=lambda *a, **k: None,
),
)
w = WillWidget(parent=fake_parent, will=imported)
assert w.will is imported
w2 = WillWidget(parent=fake_parent)
assert w2.will is live
# ------------------------------------------------------------------ #
# WillDetailDialog external-will mode
# ------------------------------------------------------------------ #
def _make_fake_bal_window(window_widget):
bal_plugin = SimpleNamespace(read_file=lambda path: b"")
return SimpleNamespace(
window=window_widget,
bal_plugin=bal_plugin,
wallet=SimpleNamespace(),
show_transaction=lambda *a, **k: None,
willitems=_make_willitems(1),
will_settings={"real_threshold": 9999},
)
def test_will_detail_dialog_external_threshold():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(2)
# locktimes 1000 and 1001 -> threshold must be the max (1001).
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.will is imported
assert dialog.threshold == 1001
dialog2 = WillDetailDialog(bal_window)
assert dialog2._external_will is False
assert dialog2.will is bal_window.willitems
assert dialog2.threshold == 9999
def test_will_detail_dialog_buttons_pass_will():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(1)
dialog = WillDetailDialog(bal_window, will=imported)
calls = []
bal_window.ask_password_and_sign_transactions = lambda **k: calls.append(
("sign", k.get("will"))
)
bal_window.broadcast_transactions = lambda **k: calls.append(
("broadcast", k.get("will"))
)
bal_window.export_will = lambda **k: calls.append(("export", k.get("will")))
bal_window.invalidate_will = lambda **k: calls.append(
("invalidate", k.get("will"))
)
dialog.ask_password_and_sign_transactions()
dialog.broadcast_transactions()
dialog.export_will()
dialog.invalidate_will()
assert len(calls) == 4
for action, will in calls:
assert will is imported, f"{action} did not receive the imported will"
# ------------------------------------------------------------------ #
# Merge flow (BalWindow.merge_will)
# ------------------------------------------------------------------ #
def _make_partial_tx(locktime=1000, signed=False):
"""A PartialTransaction derived from _VALID_TX_HEX.
When ``signed`` the input scriptSig of the raw tx is copied over, which
finalizes the (legacy P2PKH) input and makes ``is_complete()`` True.
"""
from electrum.transaction import PartialTransaction, Transaction
ptx = PartialTransaction.from_tx(Transaction(_VALID_TX_HEX))
ptx.locktime = locktime
if signed:
raw = Transaction(_VALID_TX_HEX)
ptx.inputs()[0].script_sig = raw.inputs()[0].script_sig
return ptx
def _make_willitem_with_tx(tx, key=None):
wi = WillItem(_make_willitem_dict())
wi.tx = tx
wi._id = key if key is not None else tx.txid()
return wi
def _make_merge_fake(willitems):
"""A BalWindow-like object with a wallet stub sufficient for the local
validity check that ``merge_will`` runs after merging.
"""
class FakeWallet:
def add_input_info(self, txin, **kwargs):
pass
def add_output_info(self, txout, **kwargs):
pass
def get_utxos(self):
return []
def get_tx_info(self, tx):
return SimpleNamespace(
tx_mined_status=SimpleNamespace(height=lambda: 0)
)
@property
def db(self):
return SimpleNamespace(get_transaction=lambda txid: None)
calls = []
fake = SimpleNamespace(
willitems=willitems,
will={},
wallet=FakeWallet(),
bal_window=None,
date_to_check=1700000000,
bal_plugin=SimpleNamespace(
HISTORY_LABEL=SimpleNamespace(
get=lambda: "BAL will history ({willexecutor})"
)
),
update_all=lambda: calls.append("update_all"),
)
fake.save_willitems = MethodType(window_mod.BalWindow.save_willitems, fake)
return fake, calls
def test_merge_will_same_id_unsigned_live_substitutes_signed_imported():
# The realistic "signed on another machine, imported here" case: the live
# will holds an unsigned PSBT (txid() -> None), the imported one is signed
# (complete). The transaction must be substituted and COMPLETE set.
wid = "same_id"
live_tx = _make_partial_tx(locktime=1000, signed=False)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
imported[wid].set_status("CHECKED", True)
fake, calls = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# The live WillItem object is kept (never replaced) and the signed tx
# substituted in.
assert live[wid] is live_item
assert live_item.tx is imported_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
# Operational statuses were carried over.
assert live_item.get_status("PUSHED") is True
assert live_item.get_status("CHECKED") is True
# The will was saved and the GUI refreshed.
assert wid in fake.will
assert fake.will[wid]["COMPLETE"] is True
assert "update_all" in calls
def test_merge_will_same_id_both_signed_combines():
# Live tx is signed but the COMPLETE flag was not set yet; the imported
# signed tx with the same txid must be COMBINED into the live one (the live
# tx object is kept) rather than substituted.
from electrum.transaction import Transaction
wid = "same_id_combine"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
txid = imported_tx.txid()
assert txid is not None
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# Same txid -> combine_with_other_psbt: live tx object is preserved.
assert live_item.tx is live_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
raw_sig = Transaction(_VALID_TX_HEX).inputs()[0].script_sig
assert live_item.tx.inputs()[0].script_sig == raw_sig
def test_merge_will_same_id_already_complete_never_touches_live_tx():
wid = "already_complete"
live_tx = _make_partial_tx(locktime=1000, signed=False)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("COMPLETE", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# An already-signed live will is left untouched: no combine, no substitute.
assert live_item.tx is live_tx
assert live_item.tx.is_complete() is False
assert live_item.tx.inputs()[0].script_sig is None
assert live_item.get_status("COMPLETE") is True
assert live_item.get_status("PUSHED") is True
def test_merge_will_statuses_are_monotonic():
# Statuses that are True in the live item must never be cleared by a False
# value coming from the imported item.
wid = "monotonic"
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("CONFIRMED", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("MEMPOOL", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("CONFIRMED") is True
assert live_item.get_status("MEMPOOL") is True
assert live_item.get_status("COMPLETE") is True
def test_merge_will_new_ids_added_wholesale():
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {live_tx.txid(): _make_willitem_with_tx(live_tx)}
imported_tx = _make_partial_tx(locktime=2000, signed=True)
new_id = imported_tx.txid()
imported = {new_id: _make_willitem_with_tx(imported_tx)}
imported[new_id].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
window_mod.BalWindow.merge_will(fake, imported)
assert len(live) == 2
assert live[new_id] is imported[new_id]
assert live[new_id].get_status("PUSHED") is True
assert new_id in fake.will
def test_merge_will_from_file_invalid_file_raises():
from bal.gui.qt.common import FileImportFailed
def bad_load(path):
raise ValueError("bad file")
fake = SimpleNamespace(_load_will_file=bad_load, wallet=None)
try:
window_mod.BalWindow.merge_will_from_file(fake, "/nonexistent.json")
except FileImportFailed as e:
assert "bad file" in str(e)
else:
raise AssertionError("expected FileImportFailed")
def test_merge_will_partial_signatures_update_counts():
# A live unsigned 2-of-3 multisig will merged with an imported copy that
# carries 1 signature must end up PARTIALLY_SIGNED with the sig counts
# refreshed on the live item (check_signatures runs after the merge).
from binascii import unhexlify
from electrum import crypto
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
TxOutpoint,
)
def make_multisig_tx(nsigs):
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
desc = parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs)))
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
from electrum.bitcoin import public_key_to_p2wpkh
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
ptx = PartialTransaction()
ptx.add_inputs([txin])
ptx.add_outputs([PartialTxOutput.from_address_and_value(addr, 50000)])
ptx.locktime = 1000
return ptx, desc
wid = "multisig_will"
live_tx, _ = make_multisig_tx(0)
imported_tx, _ = make_multisig_tx(1)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("PARTIALLY_SIGNED") is True
assert live_item.sigs_have == 1
assert live_item.sigs_required == 2
def test_will_detail_dialog_merge_switches_to_live():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
live = bal_window.willitems
imported = _make_willitems(2)
merged = []
bal_window.merge_will = lambda will: merged.append(will)
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.merge_button is not None
assert dialog.merge_button.isHidden() is False
dialog.merge_will()
assert merged == [imported]
assert dialog._external_will is False
assert dialog.will is live
assert dialog.threshold == bal_window.will_settings["real_threshold"]
assert dialog.merge_button.isHidden() is True
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All import-will-details tests passed")

View File

@@ -33,10 +33,9 @@ import logging
import os
import sys
import time
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from electrum import constants
constants.net = constants.BitcoinRegtest
@@ -48,10 +47,14 @@ from electrum.transaction import PartialTxInput, TxOutpoint
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.will import Will, WillItem, NoWillExecutorNotPresent, NotCompleteWillException
from bal.core.will import (
NotCompleteWillException,
NoWillExecutorNotPresent,
Will,
WillItem,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Load karen7 wallet data
# ------------------------------------------------------------------ #
@@ -479,11 +482,11 @@ class TestNoWillexecutorKaren7:
dialog = FakeBuildWillDialog(self.bal_window)
dialog.task_phase1()
assert any(
"Not present - select one or enable backup mode" in l
for l in dialog.labels
"Not present - select one or enable backup mode" in label
for label in dialog.labels
), "dialog labels must contain the 'not present' message"
assert any(
"#ff0000" in l for l in dialog.labels
"#ff0000" in label for label in dialog.labels
), "dialog labels must use red (COLOR_ERROR)"
def test_task_phase1_adds_action_buttons(self):

View File

@@ -23,9 +23,15 @@ if os.path.isdir(ELECTRUM_DIR):
sys.path.insert(0, ELECTRUM_DIR)
from bal.core.heirs import Heirs
from bal.core.willexecutors import Willexecutors
from bal.core.will import Will, WillItem, NotCompleteWillException, NoHeirsException, NoWillExecutorNotPresent
from bal.core.plugin_base import BalPlugin, BalTimestamp
from bal.core.will import (
NoHeirsException,
NotCompleteWillException,
NoWillExecutorNotPresent,
Will,
WillItem,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Load karen7 wallet data
@@ -53,7 +59,7 @@ def build_utxos(data):
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
for _, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
@@ -85,7 +91,6 @@ class FakeBalWindow:
def init_class_variables(self):
if not self.heirs:
raise NoHeirsException("Heirs are not defined")
from bal.core.plugin_base import BalTimestamp
from datetime import datetime
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()

View File

@@ -0,0 +1,136 @@
"""
Tests for the "Save inheritance transactions in history" settings dialog rows.
Covers:
- The history label line-edit is present and bound to the HISTORY_LABEL
config (its text is the configured label).
- The rows are hidden in BASIC mode and visible in ADVANCED mode (advanced
-only settings, mirroring the other advanced rows).
- The history label line-edit is disabled while the "Save inheritance
transactions in history" checkbox is off, and re-enabled when it is on.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_settings_history_dialog.py
"""
import sys
import tempfile
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import patch
from PyQt6.QtWidgets import QApplication
_app = QApplication.instance() or QApplication(sys.argv)
from electrum.simple_config import SimpleConfig
import bal.gui.qt.plugin as plugin_mod
from bal.gui.qt.plugin import Plugin
from bal.gui.qt.widgets import BalCheckBox, BalLineEdit
DEFAULT_LABEL = "BitcoinAfterLife inheritance transaction - {willexecutor}"
def _isolated_config(**overrides):
"""An in-memory SimpleConfig that never touches the real Electrum config.
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
the tests cannot pollute the user's config files.
"""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
return SimpleConfig(opts)
def _build_dialog(user_type, **config_overrides):
"""Build the plugin settings dialog for *user_type* and return it.
``settings_dialog`` ends with a blocking ``show_modal(d)`` call; we patch it
to capture the dialog and return immediately.
"""
cfg = _isolated_config(**config_overrides)
plugin = Plugin(None, cfg, "bal")
plugin.get_window_title = lambda s: s
plugin.read_file = lambda *a: b""
plugin.broadcast_transactions = lambda *a, **k: None
plugin.update_all = lambda *a, **k: None
plugin.USER_TYPE.set(user_type)
captured = []
def fake_show_modal(dlg):
captured.append(dlg)
return True
with patch.object(plugin_mod, "show_modal", side_effect=fake_show_modal):
plugin.settings_dialog(None, None)
assert captured, "settings_dialog did not build a dialog"
return plugin, captured[0]
def _history_label_edit(dialog):
edits = [
w for w in dialog.findChildren(BalLineEdit) if w.text() == DEFAULT_LABEL
]
assert len(edits) == 1, f"expected exactly one history label edit, got {len(edits)}"
return edits[0]
def test_history_label_row_hidden_in_basic_visible_in_advanced():
plugin, basic = _build_dialog("basic")
assert _history_label_edit(basic).isHidden() is True
basic.close()
plugin, advanced = _build_dialog("advanced")
edit = _history_label_edit(advanced)
assert edit.isHidden() is False
advanced.close()
def test_history_label_field_follows_checkbox():
# Default: SAVE_HISTORY is ON, so the label field starts enabled.
plugin, dialog = _build_dialog("advanced")
edit = _history_label_edit(dialog)
assert edit.isEnabled() is True
# Find the checkbox that controls the label field's enabled state: it must
# be the "Save inheritance transactions in history" checkbox (the only one
# whose off-state disables the label field).
toggler = None
for box in dialog.findChildren(BalCheckBox):
if not box.isChecked():
continue
box.setChecked(False)
if not edit.isEnabled():
toggler = box
break
assert toggler is not None, "no checkbox disables the history label field"
# Toggling it back on re-enables the field.
toggler.setChecked(True)
assert edit.isEnabled() is True
assert plugin.SAVE_HISTORY.get() is True
dialog.close()
def test_history_label_field_disabled_from_start_when_off():
plugin, dialog = _build_dialog(
"advanced", **{"bal_save_history": False}
)
assert _history_label_edit(dialog).isEnabled() is False
dialog.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All settings-history dialog tests passed")

View File

@@ -45,10 +45,10 @@ class _WindowsLikeDatetime(_real_datetime):
def main():
plugin_base = importlib.import_module(f"{PKG}.core.plugin_base")
BalTimestamp = plugin_base.BalTimestamp
bt_class = plugin_base.BalTimestamp
# 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already.
bt = BalTimestamp(NLOCKTIME_MAX)
bt = bt_class(NLOCKTIME_MAX)
d = bt.to_date()
assert isinstance(d, _real_datetime), d
print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform")
@@ -59,7 +59,7 @@ def main():
plugin_base.datetime = _WindowsLikeDatetime
try:
# 2a) Absolute sentinel timestamp (the exact crash path from the log).
bt = BalTimestamp(NLOCKTIME_MAX)
bt = bt_class(NLOCKTIME_MAX)
d = bt.to_date() # must NOT raise OverflowError anymore
assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}"
print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)")
@@ -75,13 +75,13 @@ def main():
print("[OK] str()/repr() on out-of-range timestamp are safe")
# 2d) Relative durations that overflow when added (e.g. huge 'd').
bt_rel = BalTimestamp(f"{10 ** 9}d") # ~2.7M years -> overflow
bt_rel = bt_class(f"{10 ** 9}d") # ~2.7M years -> overflow
d2 = bt_rel.to_date()
assert d2 is not None
print("[OK] huge relative duration no longer raises")
# 2e) Normal values are unchanged (behaviour-preserving check).
bt_norm = BalTimestamp("90d")
bt_norm = bt_class("90d")
d3 = bt_norm.to_date()
# 90 days from now, normalised to midnight
assert d3.hour == 0 and d3.minute == 0 and d3.second == 0