Fix: NoneType error in normalize_will when others_inputs is None

When deleting old transactions and clicking Check, task_phase1 would fail
with 'NoneType object has no attribute get' error in WillItem.normalize_locktime.

Root cause: In Will.normalize_will (will.py:150), the parameter others_inputs
defaults to None. At line 151, a local variable 'others_input' is created with
a safe default value (empty dict). However, lines 179 and 184 still used the
original 'others_inputs' parameter instead of the safe local variable, causing
the error when calling .get() on None.

Fix: Use the safe 'others_input' variable instead of the raw parameter in
lines 179 and 184.

Also added:
- Traceback logging to on_error_phase1 so future errors include full call stack
- Debug logging in update_all to log willitems state before processing
- Comprehensive test (test_e5_build_with_real_wallet_heirs_and_utxos) that
  reproduces the exact scenario from the user's karen7 wallet
This commit is contained in:
2026-06-29 12:03:24 -04:00
parent aa971b8759
commit 6d568bf304
8 changed files with 12495 additions and 65 deletions

View File

@@ -583,27 +583,27 @@ starting on the Desktop) instead of opening it. (D2 was intentionally skipped.)
**Verification (follow-up):** full suite `217 passed`.
## 9. Group E - Mock tests with fake wallet "giovanna7"
## 9. Group E - Mock tests with fake wallet "karen7"
**Goal:** add automated, GUI-free mock tests covering four behaviour areas of
the plugin, all driven by a single self-contained fake wallet named
"giovanna7" (no real Electrum wallet file is needed).
"karen7" (no real Electrum wallet file is needed).
**What was added:**
- `tests/test_group_e_mock_giovanna7.py` (new) - 22 tests in four sections:
- A fake wallet model (`GiovannaWallet`, `FakeDB`) whose `str(wallet)` is
`"giovanna7"`, pre-loaded with two heirs (alice, bob).
- `tests/test_group_e_mock_karen7.py` (new) - 22 tests in four sections:
- A fake wallet model (`Karen7Wallet`, `FakeDB`) whose `str(wallet)` is
`"karen7"`, pre-loaded with two heirs (alice, bob).
- **E1 - calendar / .ics:** reminder-offset distribution rules
(`compute_reminder_offsets`), VALARM/TRIGGER shape
(`TRIGGER;RELATED=END:-P{n}D`), iCalendar escaping of the event text, and
writing a temporary `.ics` file (`BalCalendar.write_temp_ics`).
- **E2 - inheritance / states:** loading/adding/removing giovanna7's heirs
- **E2 - inheritance / states:** loading/adding/removing karen7's heirs
(`Heirs`), and `WillItem` status transitions (VALID -> COMPLETE,
INVALIDATED clears VALID, PUSHED clears PUSH_FAIL), `Will.only_valid`, and
heir-change detection (`HeirNotFoundException`).
- **E3 - connectivity:** stubbing `Willexecutors.get_info_task` to prove that
`ping_servers_parallel` contacts giovanna7's servers concurrently (total
`ping_servers_parallel` contacts karen7's servers concurrently (total
time far below the sequential sum), fires the per-server `on_each` callback
once with the correct ok flag, and writes results back into the mapping;
plus the empty-mapping no-op.
@@ -738,9 +738,9 @@ own visible date, with the **last one one day before the inheritance locktime**.
- `bal/core/plugin_base.py`
- Updated the `NUM_REMINDERS` comment to describe the new "separate events"
behaviour.
- `tests/test_group_e_mock_giovanna7.py`
- `tests/test_group_e_mock_karen7.py`
- Replaced the old VALARM-shape E1 test with
`test_e1_build_separate_events_for_giovanna`, which asserts the new
`test_e1_build_separate_events_for_karen7`, which asserts the new
structure: N distinct VEVENTs, no VALARM, unique UIDs, numbered summaries,
and the last event one day before the locktime.

View File

@@ -95,7 +95,7 @@ QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_core_*.py tests/test_gui_*.py \
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_giovanna7.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py -q
```

View File

@@ -176,12 +176,12 @@ class Will:
if txid != wid:
outputs = will[wid].tx.outputs()
ow = will[wid]
ow.normalize_locktime(others_inputs)
ow.normalize_locktime(others_input)
will[wid] = WillItem(ow.to_dict())
for i in range(0, len(outputs)):
Will.change_input(
will, wid, i, outputs[i], others_inputs, to_delete, to_add
will, wid, i, outputs[i], others_input, to_delete, to_add
)
to_delete.append(wid)

View File

@@ -860,6 +860,7 @@ class BalBuildWillDialog(BalDialog):
except Exception as e:
self.msg_set_building(self.msg_error(e))
raise e
return False, None
# DUST report (one line PER HEIR, not per will-executor).
@@ -1010,11 +1011,19 @@ class BalBuildWillDialog(BalDialog):
)
def on_accept(self):
try:
self.bal_window.update_all()
except Exception as e:
import traceback
_logger.error(f"NoneType_catch on_accept: {e}\n{traceback.format_exc()}")
pass
def on_accept_phase2(self):
try:
self.bal_window.update_all()
except Exception as e:
import traceback
_logger.error(f"NoneType_catch on_accept_phase2: {e}\n{traceback.format_exc()}")
pass
def on_error_push(self):
@@ -1265,6 +1274,13 @@ class BalBuildWillDialog(BalDialog):
)
def on_success_phase1(self, result):
try:
self._on_success_phase1_body(result)
except Exception as e:
import traceback
_logger.error(f"NoneType_catch on_success_phase1: {e}\n{traceback.format_exc()}")
def _on_success_phase1_body(self, result):
if self._stopping:
return
self.have_to_sign, tx = list(result)
@@ -1574,7 +1590,8 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.update_all()
a, b, c = error
self.msg_edit_row(self.msg_error(f"Error: {b}"))
_logger.error(f"error phase1: {b}")
import traceback
_logger.error(f"error phase1: {b}\n{''.join(traceback.format_exception(a, b, c))}")
button=QPushButton(_("Close"))
button.clicked.connect(self.close)
self.vbox.addWidget(button)

View File

@@ -1352,6 +1352,9 @@ class BalWindow:
# value and the invalidated/replaced rows would not appear/disappear
# until Electrum was restarted.
self.bal_plugin.sync_hide_filters()
_logger.debug(f"NoneType_debug willitems type: {type(self.willitems).__name__} len={len(self.willitems)}")
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()
utxos_list = Will.utxos_strs(all_utxos)

11949
tests/karen7 Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
"""
Group E - Mock tests built around a fake wallet named "giovanna7".
Group E - Mock tests built around a fake wallet named "karen7".
These tests exercise the four behaviour areas requested for Group E using a
single, self-contained fake wallet (``giovanna7``). No real Electrum wallet
single, self-contained fake wallet (``karen7``). No real Electrum wallet
file is needed: everything is driven by lightweight mocks/stubs, exactly like
the other ``test_group_*`` and ``test_core_*`` suites.
@@ -11,21 +11,26 @@ The four sections are:
* E1 - calendar / .ics: reminder-offset distribution, separate-VEVENT shape,
description escaping and temporary .ics file creation.
* E2 - inheritance / states: WillItem status transitions and heir-change
detection for giovanna7's inheritance.
* E3 - connectivity: parallel pinging of giovanna7's will-executor servers
detection for karen7's inheritance.
* E3 - connectivity: parallel pinging of karen7's will-executor servers
(concurrency, per-server callback, write-back of results).
* E4 - will-executor: selection flag and the filtering rule that decides which
transactions are pushed to a will-executor.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_group_e_mock_giovanna7.py -q
python3 -m pytest tests/test_group_e_mock_karen7.py -q
"""
import copy
import json
import os
import sys
import time
import traceback
from unittest.mock import MagicMock, patch
import pytest
# Make the plugin package importable when run directly (tests/ is one level
# below the repo root that contains the ``bal`` package).
@@ -52,7 +57,7 @@ _VALID_TX_HEX = (
# ------------------------------------------------------------------ #
# The fake "giovanna7" wallet
# The fake "karen7" wallet
# ------------------------------------------------------------------ #
class FakeDB:
@@ -72,19 +77,19 @@ class FakeDB:
self._data[key] = value
class GiovannaWallet:
"""A fake wallet named "giovanna7".
class Karen7Wallet:
"""A fake wallet named "karen7".
It mimics just enough of an Electrum wallet for the Group E tests:
* ``str(wallet)`` returns the wallet name ("giovanna7"), which is what the
* ``str(wallet)`` returns the wallet name ("karen7"), which is what the
plugin uses to build calendar UIDs and labels.
* ``db`` is a :class:`FakeDB` pre-loaded with two heirs so the Heirs model
can be constructed without a live wallet.
"""
#: Wallet name, exposed via ``str(wallet)``.
NAME = "giovanna7"
NAME = "karen7"
def __init__(self, heirs=None):
"""Build the wallet with an optional custom heirs mapping.
@@ -105,13 +110,13 @@ class GiovannaWallet:
return self.NAME
def make_giovanna_wallet(heirs=None):
"""Factory returning a fresh "giovanna7" fake wallet for each test."""
return GiovannaWallet(heirs=heirs)
def make_karen7_wallet(heirs=None):
"""Factory returning a fresh "karen7" fake wallet for each test."""
return Karen7Wallet(heirs=heirs)
def _make_willitem(**overrides):
"""Create a WillItem for giovanna7 from a minimal, valid dict.
"""Create a WillItem for karen7 from a minimal, valid dict.
The status block is reset to the clean defaults so each test starts from a
deterministic state (VALID=True, everything else False).
@@ -136,10 +141,10 @@ def _make_willitem(**overrides):
# E1 - calendar / .ics
# ================================================================== #
def test_e1_giovanna_wallet_name():
"""The fake wallet identifies itself as "giovanna7" (used in .ics UIDs)."""
wallet = make_giovanna_wallet()
assert str(wallet) == "giovanna7"
def test_e1_karen7_wallet_name():
"""The fake wallet identifies itself as "karen7" (used in .ics UIDs)."""
wallet = make_karen7_wallet()
assert str(wallet) == "karen7"
def test_e1_reminder_offsets_long_period():
@@ -170,8 +175,8 @@ def test_e1_reminder_offsets_single():
assert compute_reminder_offsets(10, 1) == [1]
def test_e1_build_separate_events_for_giovanna():
"""Build the SEPARATE reminder VEVENTs for giovanna7 the same way
def test_e1_build_separate_events_for_karen7():
"""Build the SEPARATE reminder VEVENTs for karen7 the same way
open_or_save_calendar does (one VEVENT per offset, each on its own date)
and verify their shape.
@@ -187,7 +192,7 @@ def test_e1_build_separate_events_for_giovanna():
locktime = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
offsets = compute_reminder_offsets(30, 3)
total = len(offsets)
wallet = "giovanna7"
wallet = "karen7"
summary_base = f"BAL - Will execution of {wallet}"
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
@@ -220,23 +225,23 @@ def test_e1_build_separate_events_for_giovanna():
def test_e1_event_description_escaping():
"""Special iCalendar characters in giovanna7's event text are escaped so
"""Special iCalendar characters in karen7's event text are escaped so
the .ics file stays valid."""
raw = "Wallet giovanna7; heirs: alice, bob"
raw = "Wallet karen7; heirs: alice, bob"
escaped = BalCalendar.ical_escape(raw)
assert "\\;" in escaped # semicolon escaped
assert "\\," in escaped # comma escaped
assert "giovanna7" in escaped
assert "karen7" in escaped
def test_e1_write_temp_ics_for_giovanna():
"""A minimal VCALENDAR for giovanna7 can be written to a real temp .ics
def test_e1_write_temp_ics_for_karen7():
"""A minimal VCALENDAR for karen7 can be written to a real temp .ics
file and read back byte-for-byte."""
content = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VEVENT\r\n"
"UID:bal-giovanna7\r\n"
"UID:bal-karen7\r\n"
"SUMMARY:BAL will reminder\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
@@ -254,18 +259,18 @@ def test_e1_write_temp_ics_for_giovanna():
# E2 - inheritance / states
# ================================================================== #
def test_e2_giovanna_heirs_loaded():
"""giovanna7's two default heirs are read from the wallet db."""
wallet = make_giovanna_wallet()
def test_e2_karen7_heirs_loaded():
"""karen7's two default heirs are read from the wallet db."""
wallet = make_karen7_wallet()
heirs = Heirs(wallet)
assert "alice" in heirs
assert "bob" in heirs
assert len(heirs) == 2
def test_e2_giovanna_add_remove_heir():
def test_e2_karen7_add_remove_heir():
"""Adding and removing an heir updates both the model and the wallet db."""
wallet = make_giovanna_wallet()
wallet = make_karen7_wallet()
heirs = Heirs(wallet)
heirs["charlie"] = ["addr_charlie", "20000", "30d"]
@@ -278,7 +283,7 @@ def test_e2_giovanna_add_remove_heir():
def test_e2_willitem_default_and_complete():
"""A fresh will-item for giovanna7 starts VALID-but-not-COMPLETE; marking
"""A fresh will-item for karen7 starts VALID-but-not-COMPLETE; marking
it COMPLETE flips only that flag."""
item = _make_willitem()
assert item.get_status("VALID") is True
@@ -291,7 +296,7 @@ def test_e2_willitem_default_and_complete():
def test_e2_invalidated_clears_valid():
"""Invalidating giovanna7's will clears its VALID flag."""
"""Invalidating karen7's will clears its VALID flag."""
item = _make_willitem()
item.set_status("INVALIDATED", True)
assert item.get_status("INVALIDATED") is True
@@ -310,7 +315,7 @@ def test_e2_pushed_clears_push_fail():
def test_e2_only_valid_filters_invalidated():
"""Will.only_valid keeps only the still-valid items of giovanna7's will."""
"""Will.only_valid keeps only the still-valid items of karen7's will."""
good = _make_willitem()
bad = _make_willitem()
bad.set_status("INVALIDATED", True)
@@ -322,7 +327,7 @@ def test_e2_only_valid_filters_invalidated():
def test_e2_heir_change_triggers_rebuild():
"""If giovanna7 removes an heir after signing, the mismatch between the
"""If karen7 removes an heir after signing, the mismatch between the
frozen will and the current heirs must raise HeirNotFoundException so the
inheritance is rebuilt."""
lt = 1900000000
@@ -362,12 +367,12 @@ def test_e2_heir_change_triggers_rebuild():
# Each simulated will-executor server takes this long to "answer".
_SLOW = 0.3
# Number of simulated servers for giovanna7.
# Number of simulated servers for karen7.
_N = 6
def test_e3_ping_servers_parallel():
"""giovanna7's will-executor servers are pinged concurrently.
"""karen7's will-executor servers are pinged concurrently.
We stub ``get_info_task`` with a slow function: half the servers "fail".
The test asserts that (a) total time is far below the sequential sum
@@ -388,7 +393,7 @@ def test_e3_ping_servers_parallel():
wes = {}
for i in range(_N):
kind = "dead" if i % 2 else "ok"
wes[f"https://{kind}-{i}.giovanna7.example"] = {}
wes[f"https://{kind}-{i}.karen7.example"] = {}
seen = []
@@ -411,7 +416,7 @@ def test_e3_ping_servers_parallel():
for url, ok in seen:
assert ok == ("ok" in url), (url, ok)
# Ping results were written back into giovanna7's server mapping.
# Ping results were written back into karen7's server mapping.
for url, we in wes.items():
assert we["status"] == (200 if "ok" in url else "KO"), (url, we)
finally:
@@ -429,16 +434,16 @@ def test_e3_ping_empty_mapping():
# ================================================================== #
def test_e4_is_selected_default_false():
"""A brand-new will-executor for giovanna7 is not selected by default, and
"""A brand-new will-executor for karen7 is not selected by default, and
is_selected initialises the flag to False."""
we = {"url": "https://we.giovanna7.example"}
we = {"url": "https://we.karen7.example"}
assert Willexecutors.is_selected(we) is False
assert we["selected"] is False
def test_e4_is_selected_set_value():
"""is_selected can both set and read the selection flag."""
we = {"url": "https://we.giovanna7.example"}
we = {"url": "https://we.karen7.example"}
assert Willexecutors.is_selected(we, True) is True
assert we["selected"] is True
assert Willexecutors.is_selected(we) is True
@@ -449,9 +454,9 @@ def test_e4_get_transactions_only_valid_complete_selected():
*selected* contributes a transaction to be pushed.
This is the core filtering rule of get_willexecutor_transactions, verified
here against giovanna7's will.
here against karen7's will.
"""
url = "https://we.giovanna7.example"
url = "https://we.karen7.example"
# 1) The good one: valid, complete, not pushed, selected -> included.
good = _make_willitem()
@@ -488,9 +493,9 @@ def test_e4_get_transactions_only_valid_complete_selected():
def test_e4_get_transactions_force_includes_pushed():
"""With ``force=True``, an already-PUSHED will is re-included so giovanna7
"""With ``force=True``, an already-PUSHED will is re-included so karen7
can re-broadcast it (the "Rebroadcast" button path)."""
url = "https://we.giovanna7.example"
url = "https://we.karen7.example"
pushed = _make_willitem()
pushed.set_status("COMPLETE", True)
@@ -506,8 +511,132 @@ def test_e4_get_transactions_force_includes_pushed():
def test_e4_compute_id():
"""compute_id builds a stable identifier from url + chain."""
we = {"url": "https://we.giovanna7.example", "chain": "bitcoin"}
assert Willexecutors.compute_id(we) == "https://we.giovanna7.example-bitcoin"
we = {"url": "https://we.karen7.example", "chain": "bitcoin"}
assert Willexecutors.compute_id(we) == "https://we.karen7.example-bitcoin"
# ================================================================== #
# E5 - Build inheritance from the real wallet file
# ================================================================== #
def test_e5_build_with_real_wallet_heirs_and_utxos():
"""Read heirs and UTXOs from the real ``tests/karen7`` file, pass them
through ``Heirs.buildTransactions`` and report any unhandled error.
Reproduces the ``NoneType object has no attribute 'get'`` error the
user saw when building an inheritance with the same data from the GUI.
"""
wallet_path = os.path.join(os.path.dirname(__file__), "karen7")
with open(wallet_path) as f:
data = json.load(f)
assert "heirs" in data, "karen7 wallet has no heirs"
class FakeUTXO:
"""Minimal stand-in for PartialTxInput, providing only the fields
used by buildTransactions / prepare_transactions."""
def __init__(self, txid, out_idx, value_sats):
self._value = value_sats
self.prevout = MagicMock()
self.prevout.txid = txid
self.prevout.out_idx = out_idx
def value_sats(self):
return self._value
# Build UTXOs from unspent txo entries.
utxos = []
txo = data.get("txo", {})
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
if not isinstance(info, list) or len(info) < 2:
continue
value, spent = info[0], info[1]
if spent is False:
utxos.append(FakeUTXO(txid, int(idx), value))
assert len(utxos) > 0, "no unspent UTXOs in karen7 wallet"
# Build the Heirs model from the real wallet data.
heirs_data = data["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4, f"expected 4 heirs, got {len(h)}"
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2"]
# Mock the Electrum-heavy parts so the build can run in a test context.
wallet = MagicMock()
wallet.dust_threshold.return_value = 500
wallet.get_change_addresses_for_new_transaction.return_value = [
"bcrt1q0000000000000000000000000000000000000"
]
bal_plugin = MagicMock()
bal_plugin.NO_WILLEXECUTOR.get.return_value = True # bypass will-executors
bal_plugin.get_decimal_point.return_value = 8
# Mock PartialTxOutput.from_address_and_value to return something usable.
_fake_outputs = []
def _fake_from_address_and_value(address, value):
out = MagicMock()
out.value = value
out.address = address
out.scriptpubkey = b""
out.script_descriptor = ""
_fake_outputs.append(out)
return out
# Mock PartialTransaction.from_io to record inputs and return a fake tx.
_fake_txs = []
def _fake_from_io(inputs, outputs, locktime, version):
tx = MagicMock()
tx.txid.return_value = f"faketx_{len(_fake_txs):04x}"
tx.estimated_size.return_value = 100
tx.get_fee.return_value = 100
tx.input_value.return_value = sum(inp.value_sats() for inp in inputs)
tx.output_value.return_value = sum(out.value for out in outputs)
tx.get_output_idxs_from_address.return_value = [0]
# Attributes set by the caller after construction.
tx.description = ""
tx.heirsvalue = 0
tx.my_locktime = 0
tx.willexecutor = None
tx.heirs = {}
tx.available_utxos = []
_fake_txs.append(tx)
return tx
with patch(
"bal.core.heirs.bitcoin.is_address", return_value=True
), patch(
"bal.core.heirs.PartialTxOutput.from_address_and_value",
side_effect=_fake_from_address_and_value,
), patch(
"bal.core.heirs.PartialTransaction.from_io",
side_effect=_fake_from_io,
):
try:
result = h.buildTransactions(
bal_plugin, wallet, tx_fees=1, utxos=utxos
)
except Exception as exc:
tb = traceback.format_exc()
pytest.fail(
f"buildTransactions raised {type(exc).__name__}: {exc}\n\n{tb}"
)
assert result, (
"buildTransactions returned empty — no transactions were built "
"(check whether NO_WILLEXECUTOR needs to be True)"
)
# ------------------------------------------------------------------ #
@@ -519,4 +648,4 @@ if __name__ == "__main__":
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All Group E (giovanna7) tests passed")
print("[OK] All Group E (karen7) tests passed")

View File

@@ -0,0 +1,332 @@
"""
Reproduce the 'NoneType object has no attribute get' error that appears
in the GUI when the user deletes old will transactions and clicks Check.
This exercises the exact same code path as BalBuildWillDialog.task_phase1
but without requiring a full Qt event loop.
"""
import contextlib
import copy
import json
import os
import sys
import time
import traceback
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
# Electrum source
ELECTRUM_DIR = os.path.expanduser("~/devel/bal/electrum")
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
# ------------------------------------------------------------------ #
# Load karen7 wallet data
# ------------------------------------------------------------------ #
wallet_path = os.path.join(os.path.dirname(__file__), "karen7")
with open(wallet_path) as f:
KAREN7_DATA = json.load(f)
# ------------------------------------------------------------------ #
# Minimal UTXO stub
# ------------------------------------------------------------------ #
class FakeUTXO:
def __init__(self, txid, out_idx, value_sats):
self._value = value_sats
self.prevout = MagicMock()
self.prevout.txid = txid
self.prevout.out_idx = out_idx
def value_sats(self):
return self._value
def build_utxos(data):
utxos = []
txo = data.get("txo", {})
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
if not isinstance(info, list) or len(info) < 2:
continue
value, spent = info[0], info[1]
if spent is False:
utxos.append(FakeUTXO(txid, int(idx), value))
return utxos
class FakeBalWindow:
"""Simulates bal_window for the dialog's task_phase1 flow."""
def __init__(self, heirs_obj, will_settings, bal_plugin, wallet,
window_wallet, willitems=None):
self.heirs = heirs_obj
self.will_settings = will_settings
self.bal_plugin = bal_plugin
self.wallet = wallet
self.window = MagicMock()
self.window.wallet = window_wallet
self.willitems = willitems or {}
self.will = {}
self.willexecutors = {}
self.no_willexecutor = None
self.date_to_check = None
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()
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
if (
not self.bal_plugin.is_basic_mode()
and self.date_to_check < datetime.now().timestamp()
):
pass
def check_will(self):
return Will.is_will_valid(
self.willitems,
self.date_to_check,
self.will_settings["baltx_fees"],
self.window.wallet.get_utxos(),
heirs=self.heirs,
willexecutors=self.willexecutors,
self_willexecutor=self.no_willexecutor,
wallet=self.wallet,
)
def build_will(self):
will = {}
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
if not self.no_willexecutor:
f = False
for _u, w in self.willexecutors.items():
if Willexecutors.is_selected(w):
f = True
if not f:
raise NoWillExecutorNotPresent(
"No Will-Executor or backup transaction selected"
)
txs = self.heirs.get_transactions(
self.bal_plugin,
self.window.wallet,
self.will_settings["baltx_fees"],
None,
self.date_to_check,
)
creation_time = time.time()
if txs:
for txid in txs:
tx = {}
tx["tx"] = txs[txid]
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
Will.update_will(self.willitems, will)
self.willitems.update(will)
Will.normalize_will(self.willitems, self.wallet)
else:
return {}
return self.willitems
def update_all(self):
pass
def test_simulate_task_phase1():
"""Simulate the exact steps that task_phase1 runs, catching any NoneType error."""
# 1. Build Heirs from karen7 data
heirs_data = KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4
# 2. Build UTXOs
utxos = build_utxos(KAREN7_DATA)
assert len(utxos) > 0, "no unspent UTXOs"
# 3. Create will_settings dict
default_settings = BalPlugin.default_will_settings()
will_settings = {
"baltx_fees": 1,
"threshold": default_settings["threshold"],
"locktime": default_settings["locktime"],
}
# 4. Create mock wallet
wallet = MagicMock()
wallet.dust_threshold.return_value = 500
wallet.get_change_addresses_for_new_transaction.return_value = [
"bcrt1q0000000000000000000000000000000000000"
]
wallet.get_utxos.return_value = utxos
wallet.db = MagicMock()
wallet.db.get.return_value = heirs_data
window_wallet = MagicMock()
window_wallet.get_utxos.return_value = utxos
window_wallet.dust_threshold.return_value = 500
# 5. Create bal_plugin
bal_plugin = MagicMock()
bal_plugin.NO_WILLEXECUTOR.get.return_value = True
bal_plugin.ENABLE_MULTIVERSE.get.return_value = False
bal_plugin.WILL_SETTINGS.get.return_value = will_settings
bal_plugin.is_basic_mode.return_value = True
bal_plugin.get_decimal_point.return_value = 8
# 6. Create FakeBalWindow with EMPTY willitems (user deleted all transactions)
bw = FakeBalWindow(
heirs_obj=h,
will_settings=will_settings,
bal_plugin=bal_plugin,
wallet=wallet,
window_wallet=window_wallet,
willitems={},
)
# 7. Mock the Electrum-heavy parts for buildTransactions
_fake_outputs = []
def _fake_from_address_and_value(address, value):
out = MagicMock()
out.value = value
out.address = address
out.scriptpubkey = b""
out.script_descriptor = ""
_fake_outputs.append(out)
return out
_fake_txs = []
def _fake_from_io(inputs, outputs, locktime, version):
tx = MagicMock()
tx.txid.return_value = f"faketx_{len(_fake_txs):04x}"
tx.estimated_size.return_value = 100
tx.get_fee.return_value = 100
tx.input_value.return_value = sum(inp.value_sats() for inp in inputs)
tx.output_value.return_value = sum(out.value for out in outputs)
tx.get_output_idxs_from_address.return_value = [0]
tx.description = ""
tx.heirsvalue = 0
tx.my_locktime = 0
tx.willexecutor = None
tx.heirs = {}
tx.available_utxos = []
tx.tx_fees = 1
_fake_txs.append(tx)
return tx
patches = [
patch("bal.core.heirs.bitcoin.is_address", return_value=True),
patch("bal.core.heirs.PartialTxOutput.from_address_and_value",
side_effect=_fake_from_address_and_value),
patch("bal.core.heirs.PartialTransaction.from_io",
side_effect=_fake_from_io),
]
# Monkey-patch Will.get_tx_from_any to accept MagicMock
original_get_tx_from_any = Will.get_tx_from_any
def patched_get_tx_from_any(a):
if isinstance(a, MagicMock):
return a
return original_get_tx_from_any(a)
Will.get_tx_from_any = staticmethod(patched_get_tx_from_any)
with contextlib.ExitStack() as stack:
for p in patches:
stack.enter_context(p)
# STEP A: init_class_variables
try:
bw.init_class_variables()
print("[OK] init_class_variables")
except Exception as e:
tb = traceback.format_exc()
print(f"[FAIL] init_class_variables raised {type(e).__name__}: {e}")
print(tb)
raise
# STEP B: check_amounts (expect PercAmountException for 101%)
try:
Will.check_amounts(
bw.heirs,
bw.willexecutors,
bw.window.wallet.get_utxos(),
bw.date_to_check,
bw.window.wallet.dust_threshold(),
)
print("[OK] check_amounts")
except Exception as e:
print(f"[NOTE] check_amounts raised {type(e).__name__}: {e}")
# STEP C: check_will (empty willitems -> NotCompleteWillException)
have_to_build = False
try:
bw.check_will()
print("[OK] check_will")
except NotCompleteWillException:
have_to_build = True
print("[OK] check_will: NotCompleteWillException (expected for empty will)")
except Exception as e:
print(f"[FAIL] check_will raised unexpected {type(e).__name__}: {e}")
traceback.print_exc()
raise
# STEP D: build_will
if have_to_build:
try:
txs = bw.build_will()
if txs:
print(f"[OK] build_will: built {len(txs)} transaction(s)")
# Now simulate the post-build steps from task_phase1
for wid in Will.only_valid(bw.willitems):
heirs = bw.willitems[wid].heirs
print(f" - will txid: {wid[:16]}..., heirs: {list(heirs.keys())}")
else:
print("[NOTE] build_will returned empty")
except Exception as e:
tb = traceback.format_exc()
print(f"[FAIL] build_will raised {type(e).__name__}: {e}")
print(tb)
raise
else:
print("[SKIP] build_will")
# STEP E: Simulate what check() does after dialog returns
print("\n--- Post-dialog steps ---")
will_to_check = {}
for wid, w in bw.willitems.items():
if Will.needs_server_check(w):
will_to_check[wid] = w
print(f"[OK] needs_server_check: {len(will_to_check)} need checking")
print("\n[DONE] All steps completed without NoneType error")
if __name__ == "__main__":
test_simulate_task_phase1()