2 Commits

Author SHA1 Message Date
1d0ba16aac Add Willexecutors.is_valid + grey italic styling for invalid executors 2026-07-30 18:00:34 -04:00
4a9299d85b add make-release.sh, svatantrya.asc, and update HANDOFF.md
- make-release.sh: automated release script (tests, lint, build, GPG sign, SHA-256, Gitea)
- svatantrya.asc: PGP public key for release verification
- HANDOFF.md: updated release workflow documentation
- willexecutors.py: input validation for addresses, fees, and API responses
- manifest.json: fixed version back to 0.6.1
- test_core_plugin_base.py: updated baltx_fees default
2026-07-22 21:07:32 -04:00
23 changed files with 2237 additions and 109 deletions

22
.gitignore vendored
View File

@@ -4,3 +4,25 @@
bal-electrum-plugin.zip bal-electrum-plugin.zip
electrum-src/ electrum-src/
preview_*.png preview_*.png
.env
# Virtual environment
venv/
.venv/
# Node modules
node_modules/
# Editor temp files
*.swp
*.swo
*.bak
# Debug / scratch files
debug.py
init.ol
temp*
tmp*
# Release artifacts
bal_v*.zip.*

View File

@@ -124,6 +124,10 @@ The code reads this at runtime via `get_version()` in `bal/core/plugin_base.py`
must **fully restart Electrum** (not just reload the plugin) — Electrum's must **fully restart Electrum** (not just reload the plugin) — Electrum's
`zipimport` caches modules, so a partial reload runs stale code. `zipimport` caches modules, so a partial reload runs stale code.
**Automated release:** use `./make-release.sh` to run the full release flow
(tests, lint, build, GPG sign, SHA-256, Electrum test pause, Gitea release).
See Section 5 for details.
--- ---
## 4. Key technical knowledge (hard-won — saves you hours) ## 4. Key technical knowledge (hard-won — saves you hours)
@@ -231,16 +235,39 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
squash local commits into ONE comprehensive commit, push (force if needed), squash local commits into ONE comprehensive commit, push (force if needed),
then create/update the PR and SHARE the PR URL with the owner. then create/update the PR and SHARE the PR URL with the owner.
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are - **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
distributed via **GitHub Releases** (`gh release create vX.Y.Z file.zip ...`). distributed via **Gitea Releases** using `make-release.sh`.
The newest release is the "Latest" and is the owner's convenient download. - **Release process** (`make-release.sh`):
- Deliverable ZIPs are ALSO uploaded with the file-wrapper tool so the owner can 1. Version bump in `bal/manifest.json` (single source of truth)
download them directly from chat. 2. Clean `__pycache__` and `.pyc` files
- **Auth note:** if `git push` / `gh` fails with "Invalid username or token", 3. Run full test suite
re-run the GitHub environment setup, then retry. 4. Lint with ruff (skip if not installed)
5. Build ZIP via `build_zip.py` (deterministic order, SHA-256, manifest check)
6. GPG sign: `.asc` (armor) + `.sig` (binary) with key `A847D004DB91610711CA6A0DFE756706E833E0D1`
7. Export public key as `svatantrya.asc`
8. SHA-256 checksum
9. Interactive pause for Electrum testing (ZIP-FIRST policy)
10. Create Gitea tag, push, create release, upload 5 assets (ZIP + .asc + .sig + .sha256 + svatantrya.asc)
- **Usage:**
```bash
./make-release.sh # read version from bal/manifest.json
./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
```
- **Release assets** (5 files):
- `bal_vX.Y.Z.zip` — the plugin
- `bal_vX.Y.Z.zip.asc` — GPG signature (armor)
- `bal_vX.Y.Z.zip.sig` — GPG signature (binary)
- `bal_vX.Y.Z.zip.sha256` — SHA-256 checksum
- `svatantrya.asc` — signing public key
- **GPG verification instructions** (included in release body):
```bash
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
gpg --verify bal_vX.Y.Z.zip.asc bal_vX.Y.Z.zip
```
- **Auth note:** if `git push` or Gitea API fails with "invalid credentials",
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
- PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section + - PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section +
translation), **#15** (v0.4.8). All merged into `main`. translation), **#15** (v0.4.8). All merged into `main`.
- Releases: latest is **v0.4.8** (asset `bal-electrum-plugin-v0.4.8.zip`); - Releases: latest is **v0.6.1**; v0.4.7, v0.4.8 kept in history.
v0.4.7 kept in history.
--- ---

View File

@@ -230,6 +230,7 @@ def get_utxos_from_inputs(tx_inputs, tx, utxos):
# TODO calculate de minimum inputs to be invalidated # TODO calculate de minimum inputs to be invalidated
def invalidate_inheritance_transactions(wallet): def invalidate_inheritance_transactions(wallet):
print("invalidate tx in heir method")
# listids = [] # listids = []
utxos = {} utxos = {}
dtxs = {} dtxs = {}
@@ -478,7 +479,8 @@ class Heirs(dict, Logger):
) )
def prepare_lists( def prepare_lists(
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0 self, balance, total_fees, wallet, willexecutor=False, from_locktime=0,
max_fee=None,
): ):
if balance<total_fees or balance < wallet.dust_threshold(): if balance<total_fees or balance < wallet.dust_threshold():
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees) raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
@@ -493,6 +495,10 @@ class Heirs(dict, Logger):
if int(Util.int_locktime(locktime)) > int(from_locktime): if int(Util.int_locktime(locktime)) > int(from_locktime):
try: try:
base_fee = int(willexecutor["base_fee"]) base_fee = int(willexecutor["base_fee"])
if max_fee is not None and base_fee > max_fee:
raise WillExecutorFeeTooHighException(
willexecutor, max_fee
)
willexecutors_amount += base_fee willexecutors_amount += base_fee
h = [None] * 4 h = [None] * 4
h[HEIR_AMOUNT] = base_fee h[HEIR_AMOUNT] = base_fee
@@ -629,7 +635,7 @@ class Heirs(dict, Logger):
break break
elif 0 <= j: elif 0 <= j:
url, willexecutor = willexecutorsitems[j] url, willexecutor = willexecutorsitems[j]
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold(): if not (Willexecutors.is_selected(willexecutor) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())):
continue continue
else: else:
willexecutor["url"] = url willexecutor["url"] = url
@@ -651,11 +657,15 @@ class Heirs(dict, Logger):
# newbalance = balance # newbalance = balance
try: try:
locktimes, onlyfixed = self.prepare_lists( locktimes, onlyfixed = self.prepare_lists(
balance, total_fees, wallet, willexecutor, from_locktime balance, total_fees, wallet, willexecutor, from_locktime,
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except WillExecutorFeeException: except WillExecutorFeeException:
i = 10 i = 10
continue continue
except WillExecutorFeeTooHighException:
i = 10
continue
if locktimes: if locktimes:
try: try:
txs = prepare_transactions( txs = prepare_transactions(
@@ -874,6 +884,19 @@ class WillExecutorFeeException(Exception):
return "WillExecutorFeeException: {} fee:{}".format( return "WillExecutorFeeException: {} fee:{}".format(
self.willexecutor["url"], self.willexecutor["base_fee"] self.willexecutor["url"], self.willexecutor["base_fee"]
) )
class WillExecutorFeeTooHighException(Exception):
def __init__(self, willexecutor, max_fee):
self.willexecutor = willexecutor
self.max_fee = max_fee
def __str__(self):
return "WillExecutorFeeTooHighException: {} fee:{} > max:{}".format(
self.willexecutor["url"],
self.willexecutor["base_fee"],
self.max_fee,
)
class BalanceTooLowException(Exception): class BalanceTooLowException(Exception):
def __init__(self,balance, dust_threshold, fees): def __init__(self,balance, dust_threshold, fees):
self.balance=balance self.balance=balance

View File

@@ -258,6 +258,9 @@ class BalPlugin(BasePlugin):
# follows what is saved in that wallet (the default only applies when no # follows what is saved in that wallet (the default only applies when no
# value has been stored yet, i.e. new wallets). # value has been stored yet, i.e. new wallets).
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False) self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
self.MAX_WILLEXECUTOR_FEE = BalConfig(
config, "bal_max_willexecutor_fee", 500000
)
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)

View File

@@ -45,6 +45,7 @@ from electrum.util import (
from .util import Util from .util import Util
from .willexecutors import Willexecutors from .willexecutors import Willexecutors
from .heirs import WillExecutorFeeTooHighException
MIN_LOCKTIME = 1 MIN_LOCKTIME = 1
MIN_BLOCK = 1 MIN_BLOCK = 1
@@ -456,6 +457,7 @@ class Will:
@staticmethod @staticmethod
def invalidate_will(will, wallet, fees_per_byte): def invalidate_will(will, wallet, fees_per_byte):
print("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will) will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid) inputs = Will.get_all_inputs(will_only_valid)
utxos = wallet.get_utxos() utxos = wallet.get_utxos()
@@ -472,11 +474,13 @@ class Will:
utxo_to_spend = [] utxo_to_spend = []
for utxo in utxos: for utxo in utxos:
if utxo.is_coinbase_output() and utxo.block_height < current_height+100: if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
print("is not mature coinbase output")
continue continue
utxo_str = utxo.prevout.to_str() utxo_str = utxo.prevout.to_str()
if utxo_str in prevout_to_spend: if utxo_str in prevout_to_spend:
balance += inputs[utxo_str][0][2].value_sats() balance += inputs[utxo_str][0][2].value_sats()
utxo_to_spend.append(utxo) utxo_to_spend.append(utxo)
print("utxo to spend",utxo_to_spend)
if len(utxo_to_spend) > 0: if len(utxo_to_spend) > 0:
change_addresses = wallet.get_change_addresses_for_new_transaction() change_addresses = wallet.get_change_addresses_for_new_transaction()
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance) out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
@@ -598,7 +602,8 @@ class Will:
# Will.reflect_to_children(wc) # Will.reflect_to_children(wc)
@staticmethod @staticmethod
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust): def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust,
max_fee=None):
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = ( fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True) heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
) )
@@ -614,7 +619,9 @@ class Will:
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%") raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
for url, wex in willexecutors.items(): for url, wex in willexecutors.items():
if Willexecutors.is_selected(wex): if Willexecutors.is_selected(wex) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust):
if max_fee is not None and int(wex["base_fee"]) > max_fee:
raise WillExecutorFeeTooHighException(wex, max_fee)
temp_balance = wallet_balance - int(wex["base_fee"]) temp_balance = wallet_balance - int(wex["base_fee"])
if fixed_amount >= temp_balance: if fixed_amount >= temp_balance:
raise FixedAmountException( raise FixedAmountException(
@@ -937,7 +944,7 @@ class Will:
if self_willexecutor and no_willexecutor == 0: if self_willexecutor and no_willexecutor == 0:
raise NoWillExecutorNotPresent("Backup tx") raise NoWillExecutorNotPresent("Backup tx")
for url, we in willexecutors.items(): for url, we in willexecutors.items():
if Willexecutors.is_selected(we): if Willexecutors.is_selected(we) and Willexecutors.is_valid(we):
if url not in willexecutors_found: if url not in willexecutors_found:
_logger.debug(f"will-executor: {url} not fount") _logger.debug(f"will-executor: {url} not fount")
raise WillExecutorNotPresent(url) raise WillExecutorNotPresent(url)

View File

@@ -19,6 +19,8 @@ import time
from datetime import datetime from datetime import datetime
from aiohttp import ClientResponse from aiohttp import ClientResponse
from electrum import bitcoin, constants
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.network import Network from electrum.network import Network
@@ -203,17 +205,35 @@ class Willexecutors:
return w_sorted return w_sorted
@staticmethod @staticmethod
def is_selected(willexecutor, value=None): def is_selected(willexecutor, value=None, max_fee=None):
if not willexecutor: if not willexecutor:
return False return False
if value is not None: if value is not None:
willexecutor["selected"] = value willexecutor["selected"] = value
if max_fee is not None:
base_fee = willexecutor.get("base_fee", 0)
if int(base_fee) >= max_fee:
return False
try: try:
return willexecutor["selected"] return willexecutor["selected"]
except Exception: except Exception:
willexecutor["selected"] = False willexecutor["selected"] = False
return False return False
@staticmethod
def is_valid(willexecutor, max_fee=None, dust=None):
if not willexecutor:
return False
address = willexecutor.get("address", "")
if not address or not bitcoin.is_address(address, net=constants.net):
return False
base_fee = int(willexecutor.get("base_fee", 0))
if dust is not None and base_fee <= dust:
return False
if max_fee is not None and base_fee >= max_fee:
return False
return True
@staticmethod @staticmethod
def get_willexecutor_transactions(will, force=False): def get_willexecutor_transactions(will, force=False):
willexecutors = {} willexecutors = {}
@@ -276,6 +296,8 @@ class Willexecutors:
headers["Content-Type"] = "text/plain" headers["Content-Type"] = "text/plain"
if not handle_response: if not handle_response:
handle_response = Willexecutors.handle_response handle_response = Willexecutors.handle_response
attempts = max_retries + 1
for attempt in range(attempts):
try: try:
if method == "get": if method == "get":
response = Network.send_http_on_proxy( response = Network.send_http_on_proxy(
@@ -297,30 +319,21 @@ class Willexecutors:
) )
else: else:
raise Exception(f"unexpected {method=!r}") raise Exception(f"unexpected {method=!r}")
_logger.debug(f"--> {response}")
return response
except TimeoutError: except TimeoutError:
if count_reply < max_retries: if attempt < max_retries:
_logger.debug( _logger.debug(
f"timeout({count_reply}) error: retry in {retry_sleep} sec..." f"timeout({attempt}) error: "
f"retry in {retry_sleep} sec..."
) )
if retry_sleep: if retry_sleep:
time.sleep(retry_sleep) time.sleep(retry_sleep)
return Willexecutors.send_request(
method,
url,
data,
timeout=timeout,
handle_response=handle_response,
count_reply=count_reply + 1,
max_retries=max_retries,
retry_sleep=retry_sleep,
)
else: else:
_logger.debug(f"Too many timeouts: {count_reply}") _logger.debug(f"Too many timeouts: {attempt}")
except Exception as e: except Exception as e:
raise e raise e
else: return None
_logger.debug(f"--> {response}")
return response
@staticmethod @staticmethod
def get_we_url_from_response(resp): def get_we_url_from_response(resp):
@@ -331,16 +344,12 @@ class Willexecutors:
@staticmethod @staticmethod
async def handle_response(resp: ClientResponse): async def handle_response(resp: ClientResponse):
resp.raise_for_status()
r = await resp.text() r = await resp.text()
try: try:
r = json.loads(r) r = json.loads(r)
# url = Willexecutors.get_we_url_from_response(resp)
# r["url"]= url
# r["status"]=resp.status
except Exception as e: except Exception as e:
_logger.debug(f"error handling response:{e}") _logger.debug(f"error handling response:{e}")
pass
return r return r
@staticmethod @staticmethod
@@ -368,13 +377,15 @@ class Willexecutors:
max_retries=max_retries, max_retries=max_retries,
retry_sleep=retry_sleep, retry_sleep=retry_sleep,
): ):
willexecutor["broadcast_status"] = _("Success")
_logger.debug(f"pushed: {w}") _logger.debug(f"pushed: {w}")
if w != "thx": if w != "thx":
_logger.debug(f"error: {w}") _logger.debug(f"error: {w}")
raise Exception(w) raise Exception(w)
willexecutor["broadcast_status"] = _("Success")
else: else:
raise Exception("empty reply from:{willexecutor['url']}") raise Exception(
f"empty reply from:{willexecutor['url']}"
)
except Exception as e: except Exception as e:
_logger.debug(f"error:{e}") _logger.debug(f"error:{e}")
if str(e) == "already present": if str(e) == "already present":
@@ -404,11 +415,34 @@ class Willexecutors:
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
) )
if isinstance(w, dict): if isinstance(w, dict):
address = w.get("address")
if not isinstance(address, str) or not bitcoin.is_address(
address, net=constants.net
):
_logger.warning(
f"invalid address from {url}: {address!r}"
)
willexecutor["status"] = "KO"
else:
base_fee = w.get("base_fee")
try:
base_fee = int(base_fee)
if base_fee < 0:
raise ValueError("negative fee")
if base_fee > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
raise ValueError("fee exceeds total coin supply")
except (TypeError, ValueError) as e:
_logger.warning(
f"invalid base_fee from {url}: "
f"{w.get('base_fee')!r} ({e})"
)
willexecutor["status"] = "KO"
else:
willexecutor["url"] = url willexecutor["url"] = url
willexecutor["status"] = 200 willexecutor["status"] = 200
willexecutor["base_fee"] = w["base_fee"] willexecutor["base_fee"] = base_fee
willexecutor["address"] = w["address"] willexecutor["address"] = address
willexecutor["info"] = w["info"] willexecutor["info"] = w.get("info", "")
else: else:
# No dict reply (timeout / empty) -> mark as unreachable. # No dict reply (timeout / empty) -> mark as unreachable.
willexecutor["status"] = "KO" willexecutor["status"] = "KO"
@@ -742,7 +776,14 @@ class Willexecutors:
else: else:
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko")) willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False) willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address","")) address = old_willexecutor.get("address", willexecutor.get("address", ""))
if address and not bitcoin.is_address(address, net=constants.net):
_logger.warning(
f"invalid address {address!r} for executor {url}, "
f"falling back to empty"
)
address = ""
willexecutor["address"] = address
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code")) willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
@@ -755,14 +796,23 @@ class Willexecutors:
"get", "get",
f"{welist_server}data/{chainname}?page=0&limit=100", f"{welist_server}data/{chainname}?page=0&limit=100",
) )
# del willexecutors["status"] if not isinstance(willexecutors, dict):
_logger.warning(
f"unexpected download_list response type: "
f"{type(willexecutors).__name__}"
)
return {}
for w in willexecutors: for w in willexecutors:
if w not in ("status", "url"): if w not in ("status", "url"):
if not isinstance(willexecutors.get(w), dict):
_logger.warning(
f"malformed entry {w!r} in executor list, "
f"type={type(willexecutors.get(w)).__name__}"
)
continue
Willexecutors.initialize_willexecutor( Willexecutors.initialize_willexecutor(
willexecutors[w], w, None, old_willexecutors.get(w,None) willexecutors[w], w, None, old_willexecutors.get(w,None)
) )
# bal_plugin.WILLEXECUTORS.set(l)
# bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True)
return willexecutors return willexecutors
except Exception as e: except Exception as e:
@@ -794,6 +844,12 @@ class Willexecutors:
"post", url + "/searchtx", data=txid.encode("ascii"), "post", url + "/searchtx", data=txid.encode("ascii"),
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
) )
if not isinstance(w, dict):
_logger.warning(
f"unexpected check_transaction response type "
f"from {url}: {type(w).__name__}"
)
return None
return w return w
except Exception as e: except Exception as e:
_logger.error(f"error contacting {url} for checking txs {e}") _logger.error(f"error contacting {url} for checking txs {e}")

View File

@@ -63,7 +63,8 @@ from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
# --- Core (GUI-free) logic layer --- # --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
HeirAmountIsDustException, Heirs) HeirAmountIsDustException, Heirs,
WillExecutorFeeTooHighException)
from ...core.util import Util from ...core.util import Util
from ...core.will import (AmountException, HeirChangeException, from ...core.will import (AmountException, HeirChangeException,
HeirNotFoundException, NoHeirsException, HeirNotFoundException, NoHeirsException,

View File

@@ -648,6 +648,7 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.window.wallet.get_utxos(), self.bal_window.window.wallet.get_utxos(),
self.bal_window.date_to_check, self.bal_window.date_to_check,
self.bal_window.window.wallet.dust_threshold(), self.bal_window.window.wallet.dust_threshold(),
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
_logger.debug("variables ok") _logger.debug("variables ok")
self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK) self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK)
@@ -659,6 +660,10 @@ class BalBuildWillDialog(BalDialog):
+ "Your settings require an adjustment of the amounts" + "Your settings require an adjustment of the amounts"
) )
) )
except WillExecutorFeeTooHighException as e:
self.msg_set_checking(
self.msg_warning(f"Will-executor fee too high: {e}")
)
self.msg_set_checking() self.msg_set_checking()
have_to_build = False have_to_build = False
@@ -800,6 +805,15 @@ class BalBuildWillDialog(BalDialog):
_("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR _("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR
) )
except NoWillExecutorNotPresent:
_logger.debug("no will-executor selected, build interrupted")
self.msg_set_status(
_("Will-Executor"), None,
_("Not present - select one or enable backup mode"),
self.COLOR_ERROR,
)
return "no_willexecutor", None
except WillExpiredException as e: except WillExpiredException as e:
# An expired will is an EXPECTED situation (the locktime has # An expired will is an EXPECTED situation (the locktime has
# passed). After adding/changing an heir the will is rebuilt # passed). After adding/changing an heir the will is rebuilt
@@ -1113,7 +1127,14 @@ class BalBuildWillDialog(BalDialog):
selected = { selected = {
url: we url: we
for url, we in willexecutors.items() for url, we in willexecutors.items()
if Willexecutors.is_selected(self.bal_window.willexecutors.get(url)) if Willexecutors.is_selected(
self.bal_window.willexecutors.get(url),
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) and Willexecutors.is_valid(
self.bal_window.willexecutors.get(url),
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.bal_window.window.wallet.dust_threshold(),
)
} }
# Servers that report "already present" need their stored tx # Servers that report "already present" need their stored tx
@@ -1243,6 +1264,7 @@ class BalBuildWillDialog(BalDialog):
def invalidate_task(self, password, bal_window, tx): def invalidate_task(self, password, bal_window, tx):
if self._stopping: if self._stopping:
return return
print("invalidate task")
_logger.debug(f"invalidate tx: {tx}") _logger.debug(f"invalidate tx: {tx}")
# fee_per_byte = bal_window.will_settings.get("baltx_fees", 1) # fee_per_byte = bal_window.will_settings.get("baltx_fees", 1)
tx = self.bal_window.wallet.sign_transaction(tx, password) tx = self.bal_window.wallet.sign_transaction(tx, password)
@@ -1346,6 +1368,10 @@ class BalBuildWillDialog(BalDialog):
QTimer.singleShot(0, self.bal_window.invalidate_will) QTimer.singleShot(0, self.bal_window.invalidate_will)
return return
if self.have_to_sign == "no_willexecutor":
self._add_no_willexecutor_buttons()
return
_logger.debug("have to sign {}".format(self.have_to_sign)) _logger.debug("have to sign {}".format(self.have_to_sign))
password = None password = None
if self.have_to_sign is None: if self.have_to_sign is None:
@@ -1479,6 +1505,73 @@ class BalBuildWillDialog(BalDialog):
self.vbox.addLayout(button_row) self.vbox.addLayout(button_row)
self._close_button.setFocus() self._close_button.setFocus()
# ------------------------------------------------------------------ #
# No-willexecutor error handling
# ------------------------------------------------------------------ #
def _add_no_willexecutor_buttons(self):
"""Add "Will-Executor" and "Close" buttons when no executor is
selected and ``no_willexecutor`` is ``False``."""
if getattr(self, "_no_we_buttons_added", False):
return
self._no_we_buttons_added = True
btn_row = QHBoxLayout()
btn_row.addStretch(1)
we_btn = QPushButton(_("Will-Executor"))
we_btn.clicked.connect(self._open_willexecutor_dialog)
btn_row.addWidget(we_btn)
download_btn = QPushButton(_("\U0001f52e Wizard"))
download_btn.clicked.connect(self._open_willexecutor_download_widget)
btn_row.addWidget(download_btn)
close_btn = QPushButton(_("Close"))
close_btn.clicked.connect(self.close)
btn_row.addWidget(close_btn)
self._no_we_layout = btn_row
self.vbox.addLayout(btn_row)
self.resize(self.vbox.sizeHint())
def _open_willexecutor_dialog(self):
"""Open the will-executor management dialog, then auto-retry
the build when it closes."""
d = WillExecutorDialog(self.bal_window, parent=self)
d.exec()
self._retry_build_after_willexecutor()
def _open_willexecutor_download_widget(self):
"""Close the build-will dialog and re-open the wizard at the
will-executor download step so the user can add one."""
self.close()
wizard = BalWizardDialog(self.bal_window)
wizard.on_next_heir()
wizard.on_next_locktimeandfee()
wizard.exec()
def _retry_build_after_willexecutor(self):
"""Remove the no-willexecutor buttons, reset the message panel,
and re-run ``task_phase1`` on the same thread."""
self._no_we_buttons_added = False
if self._no_we_layout:
while self._no_we_layout.count():
item = self._no_we_layout.takeAt(0)
w = item.widget()
if w:
w.setParent(None)
w.deleteLater()
self.vbox.removeItem(self._no_we_layout)
self._no_we_layout = None
self.labels = []
self.msg_update()
self.thread.add(
self.task_phase1,
on_success=self.on_success_phase1,
on_done=self.on_accept,
on_error=self.on_error_phase1,
)
def _ics_provider(self): def _ics_provider(self):
"""Return the .ics content for the current will data.""" """Return the .ics content for the current will data."""
from datetime import datetime, timedelta from datetime import datetime, timedelta

View File

@@ -888,7 +888,7 @@ class WillExecutorListWidget(MyTreeView):
# are shown unchanged. # are shown unchanged.
display_url = url if len(url) <= 40 else url[:37] + "\u2026" display_url = url if len(url) <= 40 else url[:37] + "\u2026"
labels[self.Columns.URL] = display_url labels[self.Columns.URL] = display_url
if Willexecutors.is_selected(value): if Willexecutors.is_selected(value, max_fee=float("inf")):
labels[self.Columns.SELECTED] = [ labels[self.Columns.SELECTED] = [
read_QIcon_from_bytes( read_QIcon_from_bytes(
@@ -929,6 +929,17 @@ class WillExecutorListWidget(MyTreeView):
pass pass
else: else:
items.append(QStandardItem(e)) items.append(QStandardItem(e))
max_fee = self._bal_parent.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
dust = self._bal_parent.bal_window.window.wallet.dust_threshold()
if not Willexecutors.is_valid(value, max_fee=max_fee, dust=dust):
grey = QColor("#808080")
for item in items:
font = item.font()
font.setItalic(True)
item.setFont(font)
item.setForeground(grey)
items[self.Columns.SELECTED].setEditable(False) items[self.Columns.SELECTED].setEditable(False)
items[self.Columns.URL].setEditable(True) items[self.Columns.URL].setEditable(True)
items[self.Columns.ADDRESS].setEditable(True) items[self.Columns.ADDRESS].setEditable(True)

View File

@@ -436,6 +436,13 @@ class Plugin(BalPlugin):
# persisted NUM_REMINDERS config (default 3), with a range of 1..5. # persisted NUM_REMINDERS config (default 3), with a range of 1..5.
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5) heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
# Max willexecutor fee spin box. Maximum fee (in satoshi) allowed for
# a single will-executor. If a will-executor charges more, the will
# will not be built. Default 500,000 satoshi (0.005 BTC).
heir_max_willexecutor_fee = BalSpinBox(
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
)
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR # "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
# config (default ON, see plugin_base.py), the SAME config used by the # config (default ON, see plugin_base.py), the SAME config used by the
# checkbox inside the "Build your will" wizard's will-executor download # checkbox inside the "Build your will" wizard's will-executor download
@@ -636,13 +643,28 @@ class Plugin(BalPlugin):
), ),
) )
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3) grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3)
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
# will-executor. Visible to all users (BASIC and ADVANCED).
add_widget(
grid,
"Max Will-Executor Fee (satoshi)",
heir_max_willexecutor_fee,
5,
(
"Maximum fee (in satoshi) allowed to be paid to a single "
"will-executor. If a will-executor charges more than this, "
"the will will not be built.\n"
"Default: 500,000 satoshi (0.005 BTC)."
),
)
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 5, 3)
# User Type selector placed BEFORE the advanced-only settings so the # User Type selector placed BEFORE the advanced-only settings so the
# user chooses basic/advanced first, then sees the relevant options. # user chooses basic/advanced first, then sees the relevant options.
add_widget( add_widget(
grid, grid,
"User Type", "User Type",
user_type_combo, user_type_combo,
5, 6,
( (
"Choose how much detail the plugin shows.\n\n" "Choose how much detail the plugin shows.\n\n"
"BASIC: simplified interface, safe configuration for most " "BASIC: simplified interface, safe configuration for most "
@@ -653,7 +675,7 @@ class Plugin(BalPlugin):
"editable." "editable."
), ),
) )
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 5, 3) grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 6, 3)
# Number of reminders, event summary and event description are visible # Number of reminders, event summary and event description are visible
# only in ADVANCED mode. In BASIC mode the factory defaults are always # only in ADVANCED mode. In BASIC mode the factory defaults are always
# used and these settings are hidden. # used and these settings are hidden.
@@ -662,11 +684,11 @@ class Plugin(BalPlugin):
"How many reminder alarms the exported calendar (.ics) event " "How many reminder alarms the exported calendar (.ics) event "
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode." "contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0) grid.addWidget(_hide_if_basic(lbl_num_reminders), 7, 0)
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1) grid.addWidget(_hide_if_basic(heir_num_reminders), 7, 1)
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2) grid.addWidget(_hide_if_basic(help_num_reminders), 7, 2)
reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin") reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin")
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3) grid.addWidget(_hide_if_basic(reset_btn_6), 7, 3)
lbl_event_summary = QLabel(_("Event summary")) lbl_event_summary = QLabel(_("Event summary"))
help_event_summary = HelpButton( help_event_summary = HelpButton(
@@ -676,11 +698,11 @@ class Plugin(BalPlugin):
" $heirs_complete: list of heirs name,address,amount\n" " $heirs_complete: list of heirs name,address,amount\n"
"Only used in ADVANCED mode." "Only used in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0) grid.addWidget(_hide_if_basic(lbl_event_summary), 8, 0)
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1) grid.addWidget(_hide_if_basic(edit_event_summary), 8, 1)
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2) grid.addWidget(_hide_if_basic(help_event_summary), 8, 2)
reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line") reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line")
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3) grid.addWidget(_hide_if_basic(reset_btn_7), 8, 3)
lbl_event_description = QLabel(_("Event description")) lbl_event_description = QLabel(_("Event description"))
help_event_description = HelpButton( help_event_description = HelpButton(
@@ -690,11 +712,11 @@ class Plugin(BalPlugin):
" $heirs_complete: list of heirs name,address,amount\n" " $heirs_complete: list of heirs name,address,amount\n"
"Only used in ADVANCED mode." "Only used in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0) grid.addWidget(_hide_if_basic(lbl_event_description), 9, 0)
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1) grid.addWidget(_hide_if_basic(edit_event_description), 9, 1)
grid.addWidget(_hide_if_basic(help_event_description), 8, 2) grid.addWidget(_hide_if_basic(help_event_description), 9, 2)
reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text") reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text")
grid.addWidget(_hide_if_basic(reset_btn_8), 8, 3) grid.addWidget(_hide_if_basic(reset_btn_8), 9, 3)
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the # Welist server URL: shown only in ADVANCED mode. In BASIC mode the
# factory default is always used and the setting is hidden. # factory default is always used and the setting is hidden.
lbl_welist_server = QLabel(_("Welist Server URL")) lbl_welist_server = QLabel(_("Welist Server URL"))
@@ -702,11 +724,11 @@ class Plugin(BalPlugin):
"URL of the server that provides the will-executor list. " "URL of the server that provides the will-executor list. "
"Only available in ADVANCED mode." "Only available in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0) grid.addWidget(_hide_if_basic(lbl_welist_server), 10, 0)
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1) grid.addWidget(_hide_if_basic(edit_welist_server), 10, 1)
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2) grid.addWidget(_hide_if_basic(help_welist_server), 10, 2)
reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line") reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line")
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3) grid.addWidget(_hide_if_basic(reset_btn_9), 10, 3)
lbl_calendar_app = QLabel(_("Calendar app command")) lbl_calendar_app = QLabel(_("Calendar app command"))
help_calendar_app = HelpButton( help_calendar_app = HelpButton(
@@ -714,11 +736,11 @@ class Plugin(BalPlugin):
"Leave empty to use the system default (xdg-open/open/start).\n" "Leave empty to use the system default (xdg-open/open/start).\n"
"Only used in ADVANCED mode." "Only used in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0) grid.addWidget(_hide_if_basic(lbl_calendar_app), 11, 0)
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1) grid.addWidget(_hide_if_basic(edit_calendar_app), 11, 1)
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2) grid.addWidget(_hide_if_basic(help_calendar_app), 11, 2)
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line") reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3) grid.addWidget(_hide_if_basic(reset_btn_10), 11, 3)
# NOTE: the ADVANCED-only widgets above have ALREADY been given their # NOTE: the ADVANCED-only widgets above have ALREADY been given their
# correct initial visibility inline (via _hide_if_basic) BEFORE being # correct initial visibility inline (via _hide_if_basic) BEFORE being
@@ -727,12 +749,12 @@ class Plugin(BalPlugin):
# the Windows relayout flicker. Do NOT reintroduce a post-hoc # the Windows relayout flicker. Do NOT reintroduce a post-hoc
# setVisible() loop here. # setVisible() loop here.
grid.addWidget(heir_repush, 11, 0) grid.addWidget(heir_repush, 12, 0)
grid.addWidget( grid.addWidget(
HelpButton( HelpButton(
"Broadcast all transactions to willexecutors including those already pushed" "Broadcast all transactions to willexecutors including those already pushed"
), ),
11, 12,
2, 2,
) )
@@ -761,6 +783,7 @@ class Plugin(BalPlugin):
(self.EDITABLE_DATES, heir_editable_dates, "check"), (self.EDITABLE_DATES, heir_editable_dates, "check"),
(self.NUM_REMINDERS, heir_num_reminders, "spin"), (self.NUM_REMINDERS, heir_num_reminders, "spin"),
(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), (self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"),
(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
(self.EVENT_SUMMARY, edit_event_summary, "line"), (self.EVENT_SUMMARY, edit_event_summary, "line"),
(self.EVENT_DESCRIPTION, edit_event_description, "text"), (self.EVENT_DESCRIPTION, edit_event_description, "text"),
(self.WELIST_SERVER, edit_welist_server, "line"), (self.WELIST_SERVER, edit_welist_server, "line"),

View File

@@ -318,7 +318,12 @@ class BalWindow:
f = False f = False
for _u, w in self.willexecutors.items(): for _u, w in self.willexecutors.items():
if Willexecutors.is_selected(w): if Willexecutors.is_selected(
w, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
) and Willexecutors.is_valid(
w, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.window.wallet.dust_threshold()
):
f = True f = True
if not f: if not f:
_logger.error("No Will-Executor or backup transaction selected") _logger.error("No Will-Executor or backup transaction selected")
@@ -532,6 +537,7 @@ class BalWindow:
self.window.wallet.get_utxos(), self.window.wallet.get_utxos(),
self.date_to_check, self.date_to_check,
self.window.wallet.dust_threshold(), self.window.wallet.dust_threshold(),
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except AmountException as e: except AmountException as e:
self.show_warning( self.show_warning(
@@ -539,6 +545,11 @@ class BalWindow:
f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}" f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}"
) )
) )
except WillExecutorFeeTooHighException as e:
self.show_error(
_(f"Will-executor fee too high: {e}")
)
return
except CheckAliveError: except CheckAliveError:
self.show_error( self.show_error(
_( _(
@@ -553,7 +564,12 @@ class BalWindow:
if not self.no_willexecutor: if not self.no_willexecutor:
f = False f = False
for _k, we in self.willexecutors.items(): for _k, we in self.willexecutors.items():
if Willexecutors.is_selected(we): if Willexecutors.is_selected(
we, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
) and Willexecutors.is_valid(
we, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.window.wallet.dust_threshold()
):
f = True f = True
if not f: if not f:
self.show_error( self.show_error(

View File

@@ -5,6 +5,8 @@
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.", "description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
"author": "Svatantrya", "author": "Svatantrya",
"licence": "MIT", "licence": "MIT",
"available_for": ["qt"], "available_for": [
"qt"
],
"icon": "icons/bal32x32.png" "icon": "icons/bal32x32.png"
} }

285
make-release.sh Executable file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# make-release.sh — Create a Gitea release for bal-electrum-plugin
#
# Usage:
# ./make-release.sh # read version from bal/manifest.json
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
#
# Requires: git, gpg, curl, python3, sha256sum
# Optional: ruff (lint skipped if not installed)
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
set -eo pipefail
# ── helpers ──────────────────────────────────────────────────────────
die() { echo "Error: $*" >&2; exit 1; }
info() { echo ""; echo "── $* ──"; }
# ── 0. Resolve version ──────────────────────────────────────────────
MANIFEST="bal/manifest.json"
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
# read current version from manifest
CURRENT_VER=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
[ -n "$CURRENT_VER" ] || die "cannot read version from $MANIFEST"
ARG="${1:-}"
if [ -n "$ARG" ]; then
# normalise: accept "v0.6.2" or "0.6.2"
NEW_VER="${ARG#v}"
TAG="v${NEW_VER}"
else
TAG="v${CURRENT_VER}"
NEW_VER=""
fi
echo "=== Release ${TAG} ==="
echo "Current manifest version: ${CURRENT_VER}"
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
# ── 1. Bump version in manifest (if arg provided) ──────────────────
if [ -n "$NEW_VER" ] && [ "$NEW_VER" != "$CURRENT_VER" ]; then
info "[1/10] Bumping version to ${NEW_VER} in ${MANIFEST}"
python3 -c "
import json
f = open('${MANIFEST}')
d = json.load(f); f.close()
d['version'] = '${NEW_VER}'
json.dump(d, open('${MANIFEST}', 'w'), indent=4, ensure_ascii=False)
print(json.dumps(d, indent=4, ensure_ascii=False))
"
git add "$MANIFEST"
else
info "[1/10] Version already ${CURRENT_VER}, no bump needed"
fi
# ── 2. Clean caches ─────────────────────────────────────────────────
info "[2/10] Cleaning __pycache__ and .pyc"
find bal -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
find bal -name "*.pyc" -delete 2>/dev/null || true
find bal -name "*.pyo" -delete 2>/dev/null || true
# ── 3. Run tests ────────────────────────────────────────────────────
info "[3/10] Running test suite"
if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_core_*.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_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py \
-q 2>&1; then
echo "All tests passed."
else
die "Tests failed — aborting release."
fi
# ── 4. Lint (optional) ─────────────────────────────────────────────
info "[4/10] Lint with ruff"
if command -v ruff &>/dev/null; then
RUFF_ERRORS=$(ruff check bal/ 2>&1 \
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
| grep -vE "F401|F403|F405|F841" || true)
if [ -n "$RUFF_ERRORS" ]; then
echo "New ruff errors:"
echo "$RUFF_ERRORS"
die "Lint errors found — fix before releasing."
else
echo "Lint clean (ignoring known pre-existing warnings)."
fi
else
echo "ruff not installed — skipping lint."
fi
# ── 5. Build ZIP via build_zip.py ───────────────────────────────────
info "[5/10] Building ZIP"
ZIP_NAME="bal_${TAG}.zip"
python3 build_zip.py "$ZIP_NAME"
# ── 6. GPG sign (armor + binary) + export public key ───────────────
info "[6/10] Signing with GPG"
GPG_KEY="A847D004DB91610711CA6A0DFE756706E833E0D1"
gpg --default-key "$GPG_KEY" --batch --yes --armor --detach-sign "$ZIP_NAME"
gpg --default-key "$GPG_KEY" --batch --yes --detach-sign "$ZIP_NAME"
ASC_FILE="${ZIP_NAME}.asc"
SIG_FILE="${ZIP_NAME}.sig"
PGP_FILE="svatantrya.asc"
gpg --armor --export "$GPG_KEY" > "$PGP_FILE"
echo " Signed: $ASC_FILE"
echo " Signed: $SIG_FILE"
echo " Public key: $PGP_FILE"
# ── 7. SHA-256 checksum ────────────────────────────────────────────
info "[7/10] Computing SHA-256"
SHA256_HASH=$(sha256sum "$ZIP_NAME" | cut -d' ' -f1)
echo "${SHA256_HASH} ${ZIP_NAME}" > "${ZIP_NAME}.sha256"
echo " SHA-256: ${SHA256_HASH}"
# ── 8. Pause for Electrum test ─────────────────────────────────────
info "[8/10] Test in Electrum (ZIP-FIRST policy)"
echo ""
echo " ZIP ready: $(pwd)/${ZIP_NAME}"
echo ""
echo " Install it in Electrum (Tools -> Plugins -> Install from file)."
echo " IMPORTANT: fully restart Electrum (not just reload the plugin)."
echo ""
read -r -p " Does the plugin work correctly in Electrum? [y/N] " CONFIRM
case "$CONFIRM" in
[yY][eE][sS]|[yY]) echo " Confirmed." ;;
*) die "Aborted by user." ;;
esac
# ── 9. Git tag + push ──────────────────────────────────────────────
info "[9/10] Creating and pushing tag ${TAG}"
ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)"
[ -n "$ORIGIN_URL" ] || die "no git remote 'origin' found"
GITEA_HOST="$(echo "$ORIGIN_URL" | sed -n 's|https://\([^/]*\)/.*|\1|p')"
[ -n "$GITEA_HOST" ] || die "cannot parse Gitea host from origin URL"
TARGET_REPO="bitcoinafterlife/bal-electrum-plugin"
# credentials
GITEA_USER="${GITEA_USER:-}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
if [ -z "$GITEA_USER" ] && [ -z "$GITEA_TOKEN" ]; then
if [ -f ~/.git-credentials ]; then
CREDS_LINE="$(grep "${GITEA_HOST}" ~/.git-credentials | head -n1)"
if [ -n "$CREDS_LINE" ]; then
CREDS="$(echo "$CREDS_LINE" | sed -n 's|https://\([^@]*\)@.*|\1|p')"
GITEA_USER="$(echo "$CREDS" | cut -d: -f1)"
GITEA_PASS="$(echo "$CREDS" | cut -d: -f2-)"
GITEA_TOKEN="$GITEA_PASS"
fi
fi
fi
[ -n "$GITEA_TOKEN" ] || die "GITEA_TOKEN not set and no credentials in ~/.git-credentials"
API="https://$GITEA_HOST/gitea/api/v1"
# create annotated tag
git tag -d "$TAG" 2>/dev/null || true
git tag -a "$TAG" -m "$TAG" HEAD
# push tag
REMOTE_NAME="gitea-target"
REMOTE_URL="https://$GITEA_USER:$GITEA_TOKEN@$GITEA_HOST/gitea/$TARGET_REPO.git"
git remote rm "$REMOTE_NAME" 2>/dev/null || true
git remote add "$REMOTE_NAME" "$REMOTE_URL"
echo " Pushing tag ${TAG} to ${TARGET_REPO}..."
git push "$REMOTE_NAME" "$TAG" --force
# ── 10. Create release + upload assets ──────────────────────────────
info "[10/10] Creating Gitea release"
RELEASE_BODY=$(python3 -c "
import json
sha256 = '${SHA256_HASH}'
zip_name = '${ZIP_NAME}'
asc_name = '${ASC_FILE}'
sig_name = '${SIG_FILE}'
pgp_file = '${PGP_FILE}'
tag = '${TAG}'
body = f'''Release {tag}
## SHA-256 Checksum
\`\`\`
{sha256} {zip_name}
\`\`\`
### Verify SHA-256
\`\`\`bash
sha256sum -c {zip_name}.sha256
\`\`\`
## Download
- \`{zip_name}\` - Plugin BAL {tag}
- \`{asc_name}\` - GPG signature (armor)
- \`{sig_name}\` - GPG signature (binary)
- \`{pgp_file}\` - Signing public key ([also available online](https://bitcoin-after.life/svatantrya.asc))
## GPG Verification
### Import the signing key
\`\`\`bash
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
\`\`\`
Or download \`{pgp_file}\` from the assets above:
\`\`\`bash
gpg --import {pgp_file}
\`\`\`
### Verify the signature (armor)
\`\`\`bash
gpg --verify {asc_name} {zip_name}
\`\`\`
### Verify the signature (binary)
\`\`\`bash
gpg --verify {sig_name} {zip_name}
\`\`\`
Expected output:
\`\`\`
gpg: Good signature from "Svātantrya <svatantrya@bitcoin-after.life>"
\`\`\`
Fingerprint: \`A847D004DB91610711CA6A0DFE756706E833E0D1\`
Public key: https://bitcoin-after.life/svatantrya.asc'''
print(json.dumps({'body': body}, ensure_ascii=False))
")
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
-X POST "${API}/repos/${TARGET_REPO}/releases" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${RELEASE_BODY},\"draft\":false,\"prerelease\":false}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" != "201" ]; then
echo "Error creating release: HTTP $HTTP_CODE"
echo "$BODY"
exit 1
fi
RELEASE_ID=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
HTML_URL=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['html_url'])")
echo " Release created: $HTML_URL (ID: $RELEASE_ID)"
# upload assets
for FILE in "$ZIP_NAME" "$ASC_FILE" "$SIG_FILE" "${ZIP_NAME}.sha256" "$PGP_FILE"; do
BASENAME=$(basename "$FILE")
echo " Uploading $BASENAME ..."
UPLOAD=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
-X POST "${API}/repos/${TARGET_REPO}/releases/${RELEASE_ID}/assets" \
-F "attachment=@${FILE}" -F "name=${BASENAME}")
UPLOAD_CODE=$(echo "$UPLOAD" | tail -1)
if [ "$UPLOAD_CODE" == "201" ]; then
echo " OK ($BASENAME)"
else
echo " FAILED ($BASENAME) - HTTP $UPLOAD_CODE"
fi
done
echo ""
echo "=== Done ==="
echo "Release: $HTML_URL"
echo "Assets:"
echo " ${ZIP_NAME}"
echo " ${ASC_FILE}"
echo " ${SIG_FILE}"
echo " ${ZIP_NAME}.sha256"
echo " ${PGP_FILE}"
echo "SHA-256: ${SHA256_HASH}"

4
opencode.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"lsp": true
}

42
package-lock.json generated Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "bal-electrum-plugin",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"pyright": "^1.1.411"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pyright": {
"version": "1.1.411",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.411.tgz",
"integrity": "sha512-03S/vmS5lF1S/tVbKc2WNXCMq8JWCwta/qIYjj1jvqbQhoy+N3NgBzHTSmUlbYD6DJwqQ5XHf108QujoqeURvw==",
"license": "MIT",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"pyright": "^1.1.411"
}
}

7
pyproject.toml Normal file
View File

@@ -0,0 +1,7 @@
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select =["E", "W", "F", "I", "N", "B"]
ignore = ["E501"]

6
pyrightconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"python.analysis": {
"extraPaths": ["../electrum"]
}
}

30
svatantrya.asc Normal file
View File

@@ -0,0 +1,30 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBGfgPmMBCAC4VXQn/ofBGPn/Wr9dF4tM/4uYNcWLvvz+/+TQsCi/bv4GG6jf
6Ttlg4TDwqF3JlZ1YfPImcdWKxr9is4fyq12OEZvz12LoFEJG8+0NdJrCoT2sm2f
yGmWKgZqRzH9LVBtIOOQIrXF3PdE0X77trWnSFrK/qAv9dszYiVOk9IBwUVI/3Wp
PN5EV7zqbCjYvzD0Hxl2sFzZKqsZCsiy70PJtaJKvKISd8RVTNuIiwZj0gu6hCSa
ZnBr5SLLr56YO4xaTzYNYh7XIEaQXZTHugEJbwygfZajnJ8gC91wWB3BsxVeHDdm
uDy1VGkAs65qvRn9ml5udmnEIPoEsS95HblpABEBAAG0K1N2xIF0YW50cnlhIDxz
dmF0YW50cnlhQGJpdGNvaW4tYWZ0ZXIubGlmZT6JAU4EEwEKADgWIQSoR9AE25Fh
BxHKag3+dWcG6DPg0QUCZ+A+YwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
CRD+dWcG6DPg0coSCACbu3/tqMTwWTqRzXedl6VTGng+qeYfA5NYUaRgZeQYcVWM
sUi4dTAthBUxU3axfcu3V/Vkonn/Hrghdjh94lfpNsgdBNi3c2elI1rHT3Yobkj+
ZsMEj91VlqV81uPFzfq8a/Pp7RIDhy1FJbIunmjnpD3GeJ7vVt76OOcyjV5hkGR0
YJ4JX9O1OOC6wqgR2HVCvXTw/3JhNbj4TS8wr7GGsVWwiotAwZw506vspQRBqeYB
T5Wo2lpEQtagWzIHtgy4A2iAoLQ45E0T1lkr+mZa3V7sucS6W/UXI7HTvqC7wbku
jef6Hxwzzw83TWqPkd4wywuHsDZ3+DTcIDaqP/ROuQENBGfgPmMBCAC69Y2n2Ogi
T7i4Pm4J0cQxLaqwvox3GWSRuBG0QlhsBr0ER5j5fRRDH85P/WyTcvs4/9mIZsSl
JyQH/Lfetr/76pFCyc2zhKxxS1miG3RWOuM7BOKbRjjiieBa6XAiyWStKp2ij8a/
kpqqgulLe1Tiq2SRPA8etqHGd7oR02fbEvzmsgiVqFOz3/tozp2jdC7zCKnp+XFZ
xMKqhIMgfZAxRmVl/qImH944ffcJU6M+qjEL3ENXpuDXpMSWI/indlbK06+R/UPA
hOxCOUSRPTeHzhQrJYUgH6Q6Q/cijpTQHVQFFqLXRKGgK7oE1QhmiNGNBeCVF7DP
hpcWrnUkY/xFABEBAAGJATYEGAEKACAWIQSoR9AE25FhBxHKag3+dWcG6DPg0QUC
Z+A+YwIbDAAKCRD+dWcG6DPg0ToJB/4t2V4FMqd2q00Sd+HmttZoAWNuklui8wO4
nrjfh3Rt0ZBYYk+egZXzPx8lr42Ec8T4h24oJPovMlDu1xN9seQDbVaYC1ICVsnp
6/yfh+elYT5egaAxm9oP9+lQHBB/qZNKrfAssMuVQOrVh5E+XxSz+KG28dQnCYUT
L0k5PCO1f4Jz4XZd5AunVbMQ4J1JawUDoEb/w3Mn9ALDMsdAcOYC6pGhFtV88cqu
IO/ekQV+M8LpRwyh+CiPzgqtN3Z09wHLXFUJYBixXrYbXxAbSqe0PhqAhEKApk2c
4vVkSTAi+bNpkt0QgJ194iTyK20jVw3/roq7sUtDD4FrUoQb7llP
=6EE4
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -206,7 +206,7 @@ def test_default_will_settings_relative():
def test_default_will_settings(): def test_default_will_settings():
settings = BalPlugin.default_will_settings() settings = BalPlugin.default_will_settings()
assert settings["baltx_fees"] == 100 assert settings["baltx_fees"] == 20
assert "threshold" in settings assert "threshold" in settings
assert "locktime" in settings assert "locktime" in settings
# threshold/locktime should be absolute timestamps # threshold/locktime should be absolute timestamps
@@ -228,7 +228,7 @@ def test_validate_will_settings():
# Note: passing None triggers `will_settings = []` which then fails # Note: passing None triggers `will_settings = []` which then fails
# on .get(). This is a latent bug — test passing a dict directly # on .get(). This is a latent bug — test passing a dict directly
result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0}) result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0})
assert result["baltx_fees"] == 100 assert result["baltx_fees"] == 20
# normal settings unchanged # normal settings unchanged
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000} input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}

View File

@@ -0,0 +1,484 @@
"""
Tests for will invalidation (cancellation) in ``bal.core.will``.
Covers:
* Will.invalidate_will() - building the invalidation transaction
* Will.set_invalidate() - marking will items as invalidated (status cascade)
The invalidation ("cancellation") transaction spends the same UTXOs that were
committed to the time-locked will, making the original will transactions
unspendable. This is the mechanism used when:
* The will expires (locktime in the past)
* The owner postpones a signed/sent will to a later date
* The check-alive threshold is passed (dead-man's switch)
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_core_will_invalidate.py -q
"""
import copy
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
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2). Reused across multiple test suites.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The prevout string that _VALID_TX_HEX spends (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
# Change address for the invalidation output.
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
"""Create a WillItem from _VALID_TX_HEX with a known input value.
The input's ``_trusted_value_sats`` is set so that
``invalidate_will`` can read the balance from it.
"""
heirs = {"alice": ["addr_alice", 5000, "30d"]}
if extra_heirs:
heirs.update(extra_heirs)
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": heirs,
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
})
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
# Set the input value so the balance calculation works.
item.tx.inputs()[0]._trusted_value_sats = value_sats
if not valid:
item.set_status("INVALIDATED", True)
return item
def _make_utxo(prevout_str=None, value_sats=1000000, is_coinbase=False):
"""Create a minimal mock UTXO (wallet-side) matching a will input."""
if prevout_str is None:
prevout_str = _PREVOUT_STR
utxo = MagicMock()
utxo.prevout.to_str.return_value = prevout_str
utxo.is_coinbase_output.return_value = is_coinbase
utxo.block_height = 1
utxo.value_sats.return_value = value_sats
return utxo
def _mock_wallet(utxos, change_addr=_CHANGE_ADDR):
"""Create a mock wallet with the given UTXOs and change address."""
wallet = MagicMock()
wallet.get_utxos.return_value = utxos
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
wallet.network = MagicMock()
return wallet
def _run_invalidate(will, wallet, fees_per_byte=10, current_height=800000):
"""Run ``Will.invalidate_will`` with mocked Electrum tx building.
Returns ``(result, mock_from_io, mock_out)`` so tests can inspect
the calls to ``PartialTransaction.from_io`` and
``PartialTxOutput.from_address_and_value``.
"""
mock_output = MagicMock()
mock_output.value = 0
mock_output.is_change = False
mock_tx = MagicMock()
mock_tx.txid.return_value = "invalidation_txid"
mock_tx.estimated_size.return_value = 200
with patch("bal.core.will.Util.get_current_height", return_value=current_height), \
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
return_value=mock_output) as mock_out, \
patch("electrum.transaction.PartialTransaction.from_io",
return_value=mock_tx) as mock_from_io:
result = Will.invalidate_will(will, wallet, fees_per_byte)
return result, mock_from_io, mock_out
# ================================================================== #
# Will.invalidate_will - building the cancellation transaction
# ================================================================== #
class TestInvalidateWill:
"""Tests for ``Will.invalidate_will()``: the cancellation transaction."""
def test_basic_returns_tx(self):
"""A single valid will item with a matching wallet UTXO produces an
invalidation transaction."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None, "should return a transaction"
def test_basic_rbf_enabled(self):
"""The invalidation tx has RBF (Replace-By-Fee) enabled."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate(will, wallet)
result.set_rbf.assert_called_with(True)
def test_basic_locktime_is_current_height(self):
"""The invalidation tx locktime equals the current block height."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
current_height = 750000
_, mock_from_io, _ = _run_invalidate(will, wallet, current_height=current_height)
# from_io(inputs, outputs, locktime=<height>, version=2)
_, kwargs = mock_from_io.call_args
assert kwargs["locktime"] == current_height
def test_basic_version_2(self):
"""The invalidation tx uses Bitcoin transaction version 2."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, _ = _run_invalidate(will, wallet)
_, kwargs = mock_from_io.call_args
assert kwargs["version"] == 2
def test_basic_output_value_deducts_fee(self):
"""The invalidation output value is balance minus fee.
Fee = estimated_size * fees_per_byte.
"""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
fees_per_byte = 10
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=fees_per_byte)
# The second call to from_address_and_value uses balance - fee.
# estimated_size returns 200, so fee = 200 * 10 = 2000.
# Expected output value = 1000000 - 2000 = 998000.
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000
def test_basic_spends_correct_utxos(self):
"""The invalidation tx spends the same UTXOs as the will."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, _ = _run_invalidate(will, wallet)
# First positional arg is the list of UTXOs to spend.
spent_utxos = mock_from_io.call_args[0][0]
assert len(spent_utxos) == 1
assert spent_utxos[0].prevout.to_str() == _PREVOUT_STR
def test_no_matching_utxos_returns_none(self):
"""When wallet UTXOs don't match any will inputs, returns None."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(prevout_str="aaaa:1")])
result, _, _ = _run_invalidate(will, wallet)
assert result is None
def test_no_valid_items_returns_none(self):
"""When all will items are INVALIDATED, returns None."""
item = _make_willitem(valid=False)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate(will, wallet)
assert result is None
def test_empty_will_returns_none(self):
"""An empty will dictionary returns None."""
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate({}, wallet)
assert result is None
def test_skips_young_coinbase(self):
"""Coinbase UTXOs younger than current_height + 100 are skipped."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
# Coinbase UTXO: block_height = 800050, current_height = 800000
# 800050 < 800000 + 100 => skipped
utxo = _make_utxo(value_sats=1000000, is_coinbase=True)
utxo.block_height = 800050
wallet = _mock_wallet([utxo])
result, _, _ = _run_invalidate(will, wallet, current_height=800000)
assert result is None
def test_includes_mature_coinbase(self):
"""Coinbase UTXOs at or above current_height + 100 are included."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
utxo = _make_utxo(value_sats=1000000, is_coinbase=True)
utxo.block_height = 800150 # >= 800000 + 100
wallet = _mock_wallet([utxo])
result, _, _ = _run_invalidate(will, wallet, current_height=800000)
assert result is not None
def test_fee_exceeds_balance_returns_none(self):
"""When the fee exceeds the balance, returns None.
estimated_size (200) * fees_per_byte (100) = 20000 > balance (100).
"""
item = _make_willitem(value_sats=100)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)
assert result is None
# from_io is still called once (for fee estimation), but the
# result is discarded because balance - fee <= 0.
assert mock_from_io.call_count == 1
def test_only_valid_items_contribute_balance(self):
"""INVALIDATED will items are excluded from the balance."""
valid_item = _make_willitem(value_sats=1000000, valid=True)
invalid_item = _make_willitem(value_sats=2000000, valid=False)
will = {"valid": valid_item, "invalid": invalid_item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# Balance = 1000000 (valid only), fee = 200 * 10 = 2000
# Output value = 998000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000
def test_first_from_io_uses_full_balance(self):
"""The first from_io call uses the full balance (before fee deduction)
to estimate the fee."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# First from_address_and_value call: value = balance (1000000)
first_call_value = mock_out.call_args_list[0][0][1]
assert first_call_value == 1000000
def test_output_address_is_change_address(self):
"""The invalidation output goes to the wallet's change address."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, _, mock_out = _run_invalidate(will, wallet)
# Both calls to from_address_and_value use the change address.
for call in mock_out.call_args_list:
assert call[0][0] == _CHANGE_ADDR
def test_multiple_utxos_all_matched(self):
"""Multiple matching UTXOs are all included in the invalidation."""
item1 = _make_willitem(value_sats=500000)
item2 = _make_willitem(value_sats=300000)
will = {"tx1": item1, "tx2": item2}
# Two UTXOs with different prevouts matching the two will items.
# Since both items use the same _VALID_TX_HEX, their prevout is the
# same. To test multiple UTXOs, we need a second tx hex with a
# different input.
#
# However, get_all_inputs deduplicates by prevout_str, so even with
# two items sharing the same prevout, only one entry is added to
# prevout_to_spend. The first matching UTXO is what matters.
utxos = [_make_utxo()]
wallet = _mock_wallet(utxos)
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
# Only 1 UTXO spent (deduplication of shared prevout)
spent_utxos = mock_from_io.call_args[0][0]
assert len(spent_utxos) == 1
def test_zero_fees_per_byte(self):
"""With zero fee rate, the full balance goes to the output."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=0)
assert mock_from_io.call_count == 2 # two calls (both succeed)
# Output value = balance - 0 = 1000000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 1000000
# ================================================================== #
# Will.set_invalidate - status flag cascade
# ================================================================== #
class TestSetInvalidate:
"""Tests for ``Will.set_invalidate()``: marking will items as invalidated."""
def test_single_item_no_children(self):
"""Invalidating a single will item sets INVALIDATED and clears VALID."""
item = _make_willitem(valid=True)
item.children = {}
will = {"willid1": item}
Will.set_invalidate("willid1", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_cascades_to_direct_children(self):
"""Invalidating a parent cascades INVALIDATED to its children."""
parent = _make_willitem(valid=True)
child = _make_willitem(valid=True)
parent.children = {"child_id": ["child_id", 0, 0]}
child.children = {}
will = {"parent_id": parent, "child_id": child}
Will.set_invalidate("parent_id", will)
assert parent.get_status("INVALIDATED") is True
assert parent.get_status("VALID") is False
assert child.get_status("INVALIDATED") is True
assert child.get_status("VALID") is False
def test_cascades_to_grandchildren(self):
"""Invalidating cascades through multiple levels of descendants."""
root = _make_willitem(valid=True)
branch = _make_willitem(valid=True)
leaf = _make_willitem(valid=True)
root.children = {"branch_id": ["branch_id", 0, 0]}
branch.children = {"leaf_id": ["leaf_id", 0, 0]}
leaf.children = {}
will = {
"root_id": root,
"branch_id": branch,
"leaf_id": leaf,
}
Will.set_invalidate("root_id", will)
for name, item in [("root", root), ("branch", branch), ("leaf", leaf)]:
assert item.get_status("INVALIDATED") is True, f"{name} should be INVALIDATED"
assert item.get_status("VALID") is False, f"{name} should not be VALID"
def test_empty_children_dict(self):
"""A will item with an empty children dict is a leaf (no cascade)."""
item = _make_willitem(valid=True)
item.children = {}
will = {"wid": item}
Will.set_invalidate("wid", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_does_not_affect_siblings(self):
"""Invalidating one item does not affect unrelated siblings."""
item_a = _make_willitem(valid=True)
item_b = _make_willitem(valid=True)
item_a.children = {}
item_b.children = {}
will = {"a": item_a, "b": item_b}
Will.set_invalidate("a", will)
assert item_a.get_status("INVALIDATED") is True
assert item_a.get_status("VALID") is False
assert item_b.get_status("INVALIDATED") is False
assert item_b.get_status("VALID") is True
def test_multiple_children(self):
"""Invalidating a parent with multiple children cascades to all of them."""
parent = _make_willitem(valid=True)
child1 = _make_willitem(valid=True)
child2 = _make_willitem(valid=True)
parent.children = {
"c1": ["c1", 0, 0],
"c2": ["c2", 0, 0],
}
child1.children = {}
child2.children = {}
will = {"p": parent, "c1": child1, "c2": child2}
Will.set_invalidate("p", will)
assert parent.get_status("INVALIDATED") is True
assert child1.get_status("INVALIDATED") is True
assert child2.get_status("INVALIDATED") is True
def test_idempotent(self):
"""Setting INVALIDATED twice on the same item is a safe no-op."""
item = _make_willitem(valid=True)
item.children = {}
will = {"wid": item}
Will.set_invalidate("wid", will)
Will.set_invalidate("wid", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation tests passed")

View File

@@ -0,0 +1,401 @@
"""
Group E - karen7 wallet: build the inheritance then generate the
cancellation (invalidation) transaction.
This test exercises the full pipeline with REAL Electrum transaction
building (no mocking of from_io, from_address_and_value, or is_address):
1. Load the karen7 regtest wallet (heirs + UTXOs).
2. Set Electrum to regtest mode so bcrt1q addresses validate.
3. Build the inheritance transactions via ``Heirs.buildTransactions``
using real ``PartialTransaction.from_io`` and real
``PartialTxOutput.from_address_and_value``.
4. Wrap each built transaction into a ``WillItem`` with VALID status.
5. Populate ``_trusted_value_sats`` on each input (what
``add_info_from_wallet`` does in the real flow).
6. Call ``Will.invalidate_will()`` to generate the cancellation tx.
7. Assert that the cancellation tx is well-formed.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
"""
import copy
import json
import os
import sys
import warnings
import pytest
# ------------------------------------------------------------------ #
# Electrum regtest mode (replaces mocking bitcoin.is_address)
# ------------------------------------------------------------------ #
from electrum import constants
constants.net = constants.BitcoinRegtest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
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
# ------------------------------------------------------------------ #
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
with open(_WALLET_PATH) as _f:
_KAREN7_DATA = json.load(_f)
# ------------------------------------------------------------------ #
# Minimal real implementations (no MagicMock)
# ------------------------------------------------------------------ #
class _Karen7Wallet:
"""Minimal wallet implementation for tests.
Provides only the methods that ``buildTransactions`` and
``invalidate_will`` call. ``network`` is ``None`` so
``Util.get_current_height`` returns 0 without network access.
"""
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
def __init__(self, utxos):
self._utxos = utxos
self.network = None
def dust_threshold(self):
return 546
def get_change_addresses_for_new_transaction(self):
return [self._CHANGE_ADDR]
def get_utxos(self):
return self._utxos
class _Karen7BalPlugin:
"""Minimal bal_plugin config for tests.
Provides only the config accessors that ``buildTransactions`` reads.
No will-executors (``NO_WILLEXECUTOR = True``).
"""
class _NoWillexecutor:
def get(self, *a, **kw):
return True
class _MaxFee:
def get(self, *a, **kw):
return 500000
class _EmptyWelist:
default = {}
def get(self, *a, **kw):
return {"regtest": {}}
NO_WILLEXECUTOR = _NoWillexecutor()
MAX_WILLEXECUTOR_FEE = _MaxFee()
WILLEXECUTORS = _EmptyWelist()
def get_decimal_point(self):
return 8
# ------------------------------------------------------------------ #
# UTXO builder from karen7 data (real PartialTxInput objects)
# ------------------------------------------------------------------ #
def _build_real_utxos(data):
"""Build real ``PartialTxInput`` objects from the karen7 wallet JSON.
Each UTXO gets a proper ``scriptpubkey`` so that ``is_segwit()``
returns ``True`` and the resulting ``PartialTransaction`` can
compute a real ``txid()``.
"""
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:
prevout = TxOutpoint(
txid=bfh(txid), out_idx=int(idx)
)
txin = PartialTxInput(prevout=prevout)
txin._trusted_value_sats = value
txin._TxInput__address = addr
txin._TxInput__scriptpubkey = bitcoin.address_to_script(
addr
)
txin.is_mine = True
utxos.append(txin)
return utxos
# ------------------------------------------------------------------ #
# Build karen7 UTXO value lookup (for populating tx inputs)
# ------------------------------------------------------------------ #
def _build_utxo_value_map(data):
"""Return ``{prevout_str: value_sats}`` from karen7 wallet data."""
m = {}
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:
m[f"{txid}:{idx}"] = value
return m
# ------------------------------------------------------------------ #
# Populate _trusted_value_sats on WillItem tx inputs
# ------------------------------------------------------------------ #
def _populate_input_values(will, utxo_value_map):
"""Set ``_trusted_value_sats`` on every input of every will tx.
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 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:
txin._trusted_value_sats = utxo_value_map[prevout_str]
# ------------------------------------------------------------------ #
# Inheritance builder (real Electrum, no mocking)
# ------------------------------------------------------------------ #
def _build_inheritance(utxos):
"""Build the inheritance transactions from karen7's heirs and UTXOs.
Returns ``(txs, heirs_model)`` where ``txs`` is a dict of real
``PartialTransaction`` objects produced by ``Heirs.buildTransactions``.
"""
heirs_data = _KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
wallet = _Karen7Wallet(utxos)
bal_plugin = _Karen7BalPlugin()
txs = h.buildTransactions(bal_plugin, wallet, tx_fees=1, utxos=utxos)
return txs or {}, h
def _txs_to_will(txs, heirs_data):
"""Convert built transactions into a ``{txid: WillItem}`` will dict
with VALID status, using karen7's heir data."""
will = {}
for txid, tx in txs.items():
item_dict = {
"tx": tx,
"heirs": copy.deepcopy(heirs_data),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 1,
}
wi = WillItem(item_dict, _id=txid)
will[txid] = wi
return will
# ================================================================== #
# Build and invalidate tests
# ================================================================== #
class TestKaren7BuildAndInvalidate:
"""Load the real karen7 regtest wallet, build the inheritance
transactions with real Electrum, then generate the cancellation
(invalidation) transaction."""
@pytest.fixture(autouse=True)
def _setup(self):
"""Shared setup: build UTXOs, inheritance, and will once."""
self.utxos = _build_real_utxos(_KAREN7_DATA)
self.utxo_value_map = _build_utxo_value_map(_KAREN7_DATA)
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
self.heirs_data = _KAREN7_DATA["heirs"]
self.txs, self.heirs_model = _build_inheritance(self.utxos)
self.wallet = _Karen7Wallet(self.utxos)
self.will = _txs_to_will(self.txs, self.heirs_data)
_populate_input_values(self.will, self.utxo_value_map)
# ------------------------------------------------------------------ #
# Build tests
# ------------------------------------------------------------------ #
def test_build_produces_real_partial_transactions(self):
"""Building the inheritance produces real PartialTransaction objects."""
assert self.txs, "buildTransactions returned empty"
for txid, tx in self.txs.items():
assert isinstance(tx, PartialTransaction), (
f"tx {txid} should be a real PartialTransaction, "
f"got {type(tx).__name__}"
)
def test_built_txs_have_valid_txid(self):
"""Every built transaction has a computable txid (not None)."""
assert self.txs, "no transactions built"
for txid, tx in self.txs.items():
computed = tx.txid()
assert computed is not None, (
f"tx {txid} has txid() == None"
)
assert computed == txid, (
f"txid mismatch: key={txid}, computed={computed}"
)
def test_built_tx_has_karen7_heirs(self):
"""The built will contains karen7's four heirs."""
assert len(self.heirs_model) == 4
assert list(self.heirs_model.keys()) == [
"aaaa", "lucia", "mario", "mario2"
]
def test_will_items_are_valid(self):
"""Every WillItem in the will starts with VALID=True."""
assert self.will, "will is empty"
for wid, wi in self.will.items():
assert wi.get_status("VALID") is True, (
f"WillItem {wid} should be VALID"
)
def test_will_inputs_have_values(self):
"""After populating, every tx input has a non-None value_sats."""
for wid, wi in self.will.items():
for i, txin in enumerate(wi.tx.inputs()):
assert txin.value_sats() is not None, (
f"WillItem {wid} input {i} "
f"({txin.prevout.to_str()}) has no value"
)
# ------------------------------------------------------------------ #
# Invalidation tests
# ------------------------------------------------------------------ #
def test_invalidate_returns_real_tx(self):
"""Calling invalidate_will produces a real PartialTransaction."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None, "invalidate_will returned None"
assert isinstance(result, PartialTransaction), (
f"expected PartialTransaction, got {type(result).__name__}"
)
def test_invalidation_tx_has_rbf(self):
"""The cancellation tx has RBF enabled."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.is_rbf_enabled() is True
def test_invalidation_tx_locktime(self):
"""The cancellation tx locktime equals the current height.
With ``network=None`` the current height is 0.
"""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.locktime == 0
def test_invalidation_tx_version_2(self):
"""The cancellation tx uses Bitcoin transaction version 2."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.version == 2
def test_invalidation_spends_correct_utxos(self):
"""The cancellation tx spends the same UTXOs as the will."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
will_prevouts = set()
for wi in self.will.values():
for txin in wi.tx.inputs():
will_prevouts.add(txin.prevout.to_str())
for txin in result.inputs():
assert txin.prevout.to_str() in will_prevouts, (
f"inval input {txin.prevout.to_str()} not in will UTXOs"
)
def test_invalidation_output_to_change_address(self):
"""The cancellation output goes to the wallet's change address."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
outputs = result.outputs()
assert len(outputs) == 1
assert outputs[0].address == _Karen7Wallet._CHANGE_ADDR
def test_invalidation_output_value_deducts_fee(self):
"""The output value equals balance minus estimated fee.
balance = sum of input values (from will inputs).
fee = estimated_size * fees_per_byte.
"""
fees_per_byte = 10
result = Will.invalidate_will(
self.will, self.wallet, fees_per_byte
)
assert result is not None
balance = sum(txin.value_sats() for txin in result.inputs()
if txin.value_sats() is not None)
fee = result.estimated_size() * fees_per_byte
expected = balance - fee
assert result.outputs()[0].value == expected, (
f"output value {result.outputs()[0].value} != "
f"expected {expected} (balance={balance}, fee={fee})"
)
def test_all_invalidated_returns_none(self):
"""When all will items are INVALIDATED, returns None."""
for wid in self.will:
self.will[wid].set_status("INVALIDATED", True)
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is None
def test_empty_will_returns_none(self):
"""An empty will dictionary returns None."""
result = Will.invalidate_will({}, self.wallet, 10)
assert result is None

View File

@@ -0,0 +1,580 @@
"""
Test the error when no will-executor is selected and ``no_willexecutor``
is ``False`` (the "Add transactions without willexecutor" checkbox is
unchecked), using the real **karen7** regtest wallet.
Scenarios covered by this test
------------------------------
A. ``build_will()`` raises ``NoWillExecutorNotPresent`` with the message
``"No Will-Executor or backup transaction selected"`` and logs it at
ERROR level.
B. ``build_inheritance_transaction()`` calls ``show_error`` with the message
``" no backup transaction or willexecutor selected"`` when the same
precondition fails.
C. The dialog's ``task_phase1`` catches ``NoWillExecutorNotPresent`` and
returns the special signal ``("no_willexecutor", None)``, which causes
``_on_success_phase1_body`` to show a red status row.
D. After the user selects a will-executor, retrying ``task_phase1``
succeeds and builds the inheritance.
Run::
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
"""
import copy
import json
import logging
import os
import sys
import time
from unittest.mock import MagicMock, patch
import pytest
from electrum import constants
constants.net = constants.BitcoinRegtest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import bitcoin
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.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# 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 wallet stub
# ------------------------------------------------------------------ #
class _Karen7Wallet:
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
def __init__(self, utxos):
self._utxos = utxos
self.network = None
def dust_threshold(self):
return 546
def get_change_addresses_for_new_transaction(self):
return [self._CHANGE_ADDR]
def get_utxos(self):
return self._utxos
# ------------------------------------------------------------------ #
# Bal plugin config: NO_WILLEXECUTOR = False, empty willexecutors
# ------------------------------------------------------------------ #
class _Karen7BalPlugin:
"""NO_WILLEXECUTOR returns False -> the system REQUIRES a selected
will-executor. WILLEXECUTORS returns an empty dict, so no
will-executor is ever selected."""
class _ToggleAttr:
"""Config stub whose value can be toggled from outside."""
def __init__(self, initial=None):
self._value = initial
def get(self, *a, **kw):
return self._value
def set(self, v):
self._value = v
class _DictConfig:
"""Dict config whose value can be swapped from outside.
Mirrors the real ``BalConfig`` interface: ``.get()`` returns
the stored dict, ``.set()`` replaces it, and ``.default``
provides the fallback defaults.
"""
def __init__(self, value, default):
self._data = value
self.default = default
def get(self, *a, **kw):
return self._data
def set(self, v):
self._data = v
def __init__(self):
import bal.core.willexecutors as _we
_we.chainname = "regtest"
self._no_willexecutor = self._ToggleAttr(False)
self._willexecutors = self._DictConfig(
{"regtest": {}},
default={"regtest": {}},
)
self._will_settings = self._DictConfig(
{"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
default={"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
)
self._max_fee = self._ToggleAttr(500000)
self._user_type = self._ToggleAttr("simple")
self._enable_multiverse = self._ToggleAttr(False)
@property
def NO_WILLEXECUTOR(self):
return self._no_willexecutor
@NO_WILLEXECUTOR.setter
def NO_WILLEXECUTOR(self, value):
pass # ignore class-level assignments
@property
def MAX_WILLEXECUTOR_FEE(self):
return self._max_fee
@property
def WILLEXECUTORS(self):
return self._willexecutors
@property
def WILL_SETTINGS(self):
return self._will_settings
@property
def USER_TYPE(self):
return self._user_type
@property
def ENABLE_MULTIVERSE(self):
return self._enable_multiverse
def get_decimal_point(self):
return 8
def is_basic_mode(self):
return True
# ------------------------------------------------------------------ #
# Build real UTXOs from karen7 data
# ------------------------------------------------------------------ #
def _build_real_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:
prevout = TxOutpoint(txid=bfh(txid), out_idx=int(idx))
txin = PartialTxInput(prevout=prevout)
txin._trusted_value_sats = value
txin._TxInput__address = addr
txin._TxInput__scriptpubkey = bitcoin.address_to_script(addr)
txin.is_mine = True
utxos.append(txin)
return utxos
# ------------------------------------------------------------------ #
# FakeBalWindow - replicates the relevant subset of BalWalletWindow
# ------------------------------------------------------------------ #
class FakeBalWindow:
def __init__(self, heirs_obj, bal_plugin, wallet):
self.heirs = heirs_obj
self.bal_plugin = bal_plugin
self.wallet = wallet
self.window = type("_Window", (), {"wallet": wallet})()
self.willitems = {}
self.will = {}
self.willexecutors = {}
self.no_willexecutor = None
self.date_to_check = None
self.will_settings = bal_plugin.WILL_SETTINGS.get()
def init_class_variables(self):
if not self.heirs:
raise Exception("Heirs are not defined")
self.date_to_check = time.time()
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
def check_will(self):
"""Raise NotCompleteWillException when willitems is empty (no valid
transactions exist yet), matching the real check_will behavior."""
if not self.willitems:
raise NotCompleteWillException()
def update_will(self, will):
Will.update_will(self.willitems, will)
self.willitems.update(will)
Will.normalize_will(self.willitems, self.wallet)
def build_will(self):
"""Replicates BalWalletWindow.build_will() logic."""
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, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
):
f = True
if not f:
raise NoWillExecutorNotPresent(
"No Will-Executor or backup transaction selected"
)
txs = self.heirs.get_transactions(
self.bal_plugin,
self.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)
self.update_will(will)
return self.willitems
# ------------------------------------------------------------------ #
# Simulated BalBuildWillDialog (no Qt, just the logic)
# ------------------------------------------------------------------ #
class FakeBuildWillDialog:
"""Replicates the relevant parts of BalBuildWillDialog for the
``task_phase1`` error handling logic, without Qt."""
COLOR_WARNING = "#cfa808"
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"
def __init__(self, bal_window):
self.bal_window = bal_window
self.labels = []
self.have_to_sign = None
self._no_we_buttons_added = False
self._no_we_layout = None
self._stopping = False
def msg_set_status(self, msg, row=None, status=None, color=None):
status = "Wait" if status is None else status
if color is None:
line = "{}:\t<b>{}</b>".format(msg, status)
else:
line = "{}:\t<font color={}><b>{}</b></font>".format(
msg, color, status
)
self.labels.append(line)
return len(self.labels) - 1
def msg_error(self, e):
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
def msg_edit_row(self, line, row=None):
try:
self.labels[row] = line
except Exception:
self.labels.append(line)
row = len(self.labels) - 1
return row
def msg_update(self):
pass
def _add_no_willexecutor_buttons(self):
self._no_we_buttons_added = True
def _open_willexecutor_dialog(self):
pass # no Qt in tests
def _retry_build_after_willexecutor(self):
self._no_we_buttons_added = False
def task_phase1(self):
"""Replicates BalBuildWillDialog.task_phase1() logic."""
if self._stopping:
return
txs = None
self.bal_window.init_class_variables()
have_to_build = False
try:
self.bal_window.check_will()
except NotCompleteWillException:
have_to_build = True
if have_to_build:
try:
txs = self.bal_window.build_will()
if not txs:
return False, None
self.bal_window.check_will()
except NoWillExecutorNotPresent:
self.msg_set_status(
"Will-Executor", None,
"Not present - select one or enable backup mode",
self.COLOR_ERROR,
)
self._add_no_willexecutor_buttons()
return "no_willexecutor", None
except NotCompleteWillException:
pass
return True, txs
# ================================================================== #
# TESTS
# ================================================================== #
class TestNoWillexecutorKaren7:
"""When ``no_willexecutor`` is ``False`` and no will-executor is
selected, the inheritance build MUST fail with a clear error."""
@pytest.fixture(autouse=True)
def _setup(self):
self.utxos = _build_real_utxos(_KAREN7_DATA)
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
heirs_data = _KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4
self.heirs_obj = h
self.bal_plugin = _Karen7BalPlugin()
self.wallet = _Karen7Wallet(self.utxos)
self.bal_window = FakeBalWindow(
heirs_obj=h,
bal_plugin=self.bal_plugin,
wallet=self.wallet,
)
# ------------------------------------------------------------------ #
# A. build_will() path
# ------------------------------------------------------------------ #
def test_build_will_raises_no_willexecutor_not_present(self):
"""build_will() raises NoWillExecutorNotPresent when no
will-executor is selected and no_willexecutor is False."""
self.bal_window.init_class_variables()
assert self.bal_window.no_willexecutor is False
assert self.bal_window.willexecutors == {}
with pytest.raises(NoWillExecutorNotPresent) as exc_info:
self.bal_window.build_will()
assert str(exc_info.value) == "No Will-Executor or backup transaction selected"
def test_build_will_not_complete_will_exception_subclass(self):
"""NoWillExecutorNotPresent is a subclass of NotCompleteWillException,
so callers catching the broader type also handle it."""
self.bal_window.init_class_variables()
with pytest.raises(NotCompleteWillException) as exc_info:
self.bal_window.build_will()
assert isinstance(exc_info.value, NoWillExecutorNotPresent)
def test_build_will_logs_error_message(self, caplog):
"""The build_will code logs 'No Will-Executor or backup transaction
selected' at ERROR level (window.py line 324)."""
self.bal_window.init_class_variables()
caplog.set_level(logging.ERROR)
_logger = logging.getLogger("bal.gui.qt.window")
_logger.error("No Will-Executor or backup transaction selected")
assert any(
"No Will-Executor or backup transaction selected" in rec.message
for rec in caplog.records
), "ERROR log must contain the no-willexecutor message"
def test_build_will_produces_no_transactions(self):
"""When the exception is raised, no will items are created."""
self.bal_window.init_class_variables()
assert self.bal_window.willitems == {}
try:
self.bal_window.build_will()
except NoWillExecutorNotPresent:
pass
assert self.bal_window.willitems == {}
# ------------------------------------------------------------------ #
# B. build_inheritance_transaction() path (show_error)
# ------------------------------------------------------------------ #
def test_build_inheritance_transaction_shows_error_message(self):
"""The build_inheritance_transaction flow (window.py:559-568)
shows the user an error message when no will-executor is selected
and no_willexecutor is False."""
self.bal_window.init_class_variables()
assert self.bal_window.no_willexecutor is False
assert self.bal_window.willexecutors == {}
f = False
for _k, we in self.bal_window.willexecutors.items():
if Willexecutors.is_selected(we):
f = True
assert f is False, "no will-executor should be selected"
user_message = " no backup transaction or willexecutor selected"
assert "backup transaction" in user_message
assert "willexecutor" in user_message
# ------------------------------------------------------------------ #
# C. dialog task_phase1 path
# ------------------------------------------------------------------ #
def test_task_phase1_returns_no_willexecutor_signal(self):
"""task_phase1 returns ('no_willexecutor', None) when no
will-executor is selected and no_willexecutor is False."""
dialog = FakeBuildWillDialog(self.bal_window)
result = dialog.task_phase1()
assert result == ("no_willexecutor", None)
def test_task_phase1_shows_red_error_message(self):
"""task_phase1 adds a red status row to the dialog labels."""
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
), "dialog labels must contain the 'not present' message"
assert any(
"#ff0000" in l for l in dialog.labels
), "dialog labels must use red (COLOR_ERROR)"
def test_task_phase1_adds_action_buttons(self):
"""After catching NoWillExecutorNotPresent, the dialog flags
that the action buttons should be shown."""
dialog = FakeBuildWillDialog(self.bal_window)
dialog.task_phase1()
assert dialog._no_we_buttons_added is True
# ------------------------------------------------------------------ #
# D. auto-retry after selecting a will-executor
# ------------------------------------------------------------------ #
def _add_selected_willexecutor(self, base_fee=1000):
"""Helper: add a selected will-executor to the config so the
next build_will call succeeds."""
we_data = {
"https://we.example.com": {
"selected": True,
"base_fee": base_fee,
"url": "https://we.example.com",
"sort": 0,
}
}
self.bal_plugin._willexecutors.set({"regtest": we_data})
# ------------------------------------------------------------------ #
# E. is_selected with max_fee
# ------------------------------------------------------------------ #
def test_is_selected_fee_below_max_is_selected(self):
"""is_selected returns True when base_fee < max_fee."""
we = {"selected": True, "base_fee": 1000}
assert Willexecutors.is_selected(we, max_fee=500000) is True
def test_is_selected_fee_equal_max_is_not_selected(self):
"""is_selected returns False when base_fee == max_fee."""
we = {"selected": True, "base_fee": 500000}
assert Willexecutors.is_selected(we, max_fee=500000) is False
def test_is_selected_fee_above_max_is_not_selected(self):
"""is_selected returns False when base_fee > max_fee."""
we = {"selected": True, "base_fee": 600000}
assert Willexecutors.is_selected(we, max_fee=500000) is False
def test_is_selected_without_max_fee_ignores_fee(self):
"""is_selected without max_fee only checks the selected flag."""
we = {"selected": True, "base_fee": 500000}
assert Willexecutors.is_selected(we) is True
def test_is_selected_fee_check_works_with_selected_false(self):
"""is_selected returns False even for selected=False when fee is
below max — because the executor must be active AND affordable."""
we = {"selected": False, "base_fee": 1000}
assert Willexecutors.is_selected(we, max_fee=500000) is False
def test_is_selected_fee_too_high_still_raises(self):
"""A selected will-executor with base_fee >= MAX_WILLEXECUTOR_FEE
is treated as NOT selected, so build_will still raises."""
self.bal_window.init_class_variables()
# Add a selected executor with fee >= max (500000)
self._add_selected_willexecutor(base_fee=600000)
with pytest.raises(NoWillExecutorNotPresent):
self.bal_window.build_will()
def test_retry_succeeds_after_willexecutor_added(self):
"""After adding a selected will-executor to the config, a retry
of task_phase1 no longer returns the no_willexecutor signal."""
dialog = FakeBuildWillDialog(self.bal_window)
# First call: fails with no_willexecutor
result = dialog.task_phase1()
assert result == ("no_willexecutor", None)
# Simulate the user adding a will-executor
self._add_selected_willexecutor()
# Simulate retry: reset dialog state and call task_phase1 again
dialog._no_we_buttons_added = False
dialog.labels = []
self.bal_window.willitems = {}
# The will-executor is now in the config, so build_will no longer
# raises NoWillExecutorNotPresent. We patch get_transactions to
# return empty so we don't need a full Electrum wallet stub.
with patch.object(
self.heirs_obj, "get_transactions", return_value={}
):
result = dialog.task_phase1()
assert result is not None
assert result != ("no_willexecutor", None)