Compare commits
2 Commits
649910e599
...
30a5720ceb
| Author | SHA1 | Date | |
|---|---|---|---|
|
30a5720ceb
|
|||
|
08394f4868
|
76
AGENTS.md
Normal file
76
AGENTS.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
|
||||||
|
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
|
||||||
|
|
||||||
|
## Environments (critical)
|
||||||
|
|
||||||
|
Two separate venvs; using the wrong one is the #1 mistake.
|
||||||
|
|
||||||
|
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
|
||||||
|
`source /home/steal/devel/bal/electrum/env/bin/activate`
|
||||||
|
This is an editable install of the Electrum 4.8.0 checkout at
|
||||||
|
`/home/steal/devel/bal/electrum`. Use it for anything that imports
|
||||||
|
`electrum`, runs GUI code, or runs tests.
|
||||||
|
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
|
||||||
|
import `electrum` or `PyQt6`. Do NOT use it to run tests.
|
||||||
|
|
||||||
|
The plugin's `bal/` directory is symlinked into
|
||||||
|
`electrum/electrum/plugins/bal` (internal-plugin install used during dev).
|
||||||
|
|
||||||
|
## Test & verify
|
||||||
|
|
||||||
|
Tests are **standalone scripts**, not pytest. Each `tests/test_*.py` file runs
|
||||||
|
its `test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /home/steal/devel/bal/electrum/env/bin/activate
|
||||||
|
python3 tests/test_core_heirs.py # core, no Qt needed
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
|
||||||
|
```
|
||||||
|
|
||||||
|
- Most core tests run offline (no wallet/network). Some files
|
||||||
|
(`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`)
|
||||||
|
exercise will-executor/network flows and need the live servers — don't rely on
|
||||||
|
them for quick verification.
|
||||||
|
- `tests/smoke_test.py` proves clean import under real Electrum:
|
||||||
|
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
|
||||||
|
- `tests/external_zip_test.py` loads the built zip the way Electrum's plugin
|
||||||
|
dialog does (`electrum_external_plugins.bal`); run it after `build_zip.py`.
|
||||||
|
|
||||||
|
## Lint / typecheck
|
||||||
|
|
||||||
|
- **Ruff is NOT clean** (hundreds of pre-existing errors in `bal/` and
|
||||||
|
`tests/`). Do not run `--fix` wholesale and do not try to silence everything;
|
||||||
|
just avoid adding new violations. Config: `pyproject.toml` (line-length 88,
|
||||||
|
E501 ignored).
|
||||||
|
- Lint via the repo venv: `/home/steal/devel/bal/bal-electrum-plugin/venv/bin/ruff`
|
||||||
|
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
|
||||||
|
(`extraPaths: ["../electrum"]`). Pyright reports many false positives on
|
||||||
|
dynamically-attached attrs (e.g. `self.window`, `BalPlugin.*`); don't chase
|
||||||
|
them.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
|
||||||
|
`plugin_base.py`, `util.py`). Must never import Qt.
|
||||||
|
- `bal/gui/qt/` = PyQt6 layer. `window.py` is the per-wallet controller,
|
||||||
|
`plugin.py` is the Electrum `@hooks` entry, `qt.py` is a zipimport shim.
|
||||||
|
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
|
||||||
|
`make-release.sh`).
|
||||||
|
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
|
||||||
|
registration API differs between them (`json_db.register_dict` vs
|
||||||
|
`stored_dict.register_name`).
|
||||||
|
|
||||||
|
## Build / release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256)
|
||||||
|
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
|
||||||
|
```
|
||||||
|
|
||||||
|
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
||||||
|
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
||||||
|
version there, never invent a new source of truth.
|
||||||
|
- Remote is Gitea (`origin` = bitcoin-after.life). `.env` holds a Gitea token
|
||||||
|
(gitignored, never commit it).
|
||||||
@@ -102,7 +102,7 @@ def validate_op_return_hex(data_hex: str) -> None:
|
|||||||
try:
|
try:
|
||||||
data = bytes.fromhex(data_hex)
|
data = bytes.fromhex(data_hex)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}")
|
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}") from None
|
||||||
if len(data) > 80:
|
if len(data) > 80:
|
||||||
raise NotAnAddress(
|
raise NotAnAddress(
|
||||||
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
|
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
|
||||||
@@ -205,7 +205,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
change = get_change_output(wallet, in_amount, out_amount, fee)
|
change = get_change_output(wallet, in_amount, out_amount, fee)
|
||||||
if change:
|
if change:
|
||||||
outputs.append(change)
|
outputs.append(change)
|
||||||
for i in range(0, 100):
|
for _ in range(0, 100):
|
||||||
random.shuffle(outputs)
|
random.shuffle(outputs)
|
||||||
|
|
||||||
#op_return_text = "Hello Bal!"
|
#op_return_text = "Hello Bal!"
|
||||||
@@ -281,7 +281,7 @@ def invalidate_inheritance_transactions(wallet):
|
|||||||
del dtxs[txid]
|
del dtxs[txid]
|
||||||
|
|
||||||
utxos = {}
|
utxos = {}
|
||||||
for txid, tx in dtxs.items():
|
for _, tx in dtxs.items():
|
||||||
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
||||||
|
|
||||||
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
||||||
@@ -727,7 +727,7 @@ class Heirs(dict, Logger):
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
raise e
|
raise
|
||||||
total_fees = 0
|
total_fees = 0
|
||||||
total_fees_real = 0
|
total_fees_real = 0
|
||||||
total_in = 0
|
total_in = 0
|
||||||
@@ -853,6 +853,7 @@ class Heirs(dict, Logger):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def validate_address(address):
|
def validate_address(address):
|
||||||
if is_op_return_address(address):
|
if is_op_return_address(address):
|
||||||
data_hex = address[len(OP_RETURN_PREFIX):]
|
data_hex = address[len(OP_RETURN_PREFIX):]
|
||||||
@@ -862,24 +863,27 @@ class Heirs(dict, Logger):
|
|||||||
raise NotAnAddress(f"not an address,{address}")
|
raise NotAnAddress(f"not an address,{address}")
|
||||||
return address
|
return address
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def validate_amount(amount):
|
def validate_amount(amount):
|
||||||
try:
|
try:
|
||||||
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
||||||
if famount <= 0.00000001:
|
if famount <= 0.00000001:
|
||||||
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise AmountNotValid(f"amount not properly formatted, {e}")
|
raise AmountNotValid(f"amount not properly formatted, {e}") from e
|
||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def validate_locktime(locktime, timestamp_to_check=False):
|
def validate_locktime(locktime, timestamp_to_check=False):
|
||||||
try:
|
try:
|
||||||
if timestamp_to_check:
|
if timestamp_to_check:
|
||||||
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
||||||
raise HeirExpiredException()
|
raise HeirExpiredException()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise LocktimeNotValid(f"locktime string not properly formatted, {e}")
|
raise LocktimeNotValid(f"locktime string not properly formatted, {e}") from e
|
||||||
return locktime
|
return locktime
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def validate_heir(k, v, timestamp_to_check=False):
|
def validate_heir(k, v, timestamp_to_check=False):
|
||||||
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
||||||
if is_op_return_address(v[HEIR_ADDRESS]):
|
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||||
@@ -889,6 +893,7 @@ class Heirs(dict, Logger):
|
|||||||
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
||||||
return (address, amount, locktime)
|
return (address, amount, locktime)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _validate(data, timestamp_to_check=False):
|
def _validate(data, timestamp_to_check=False):
|
||||||
|
|
||||||
for k, v in list(data.items()):
|
for k, v in list(data.items()):
|
||||||
|
|||||||
@@ -228,6 +228,21 @@ class BalPlugin(BasePlugin):
|
|||||||
self.PREVIEW = BalConfig(config, "bal_preview", True)
|
self.PREVIEW = BalConfig(config, "bal_preview", True)
|
||||||
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
||||||
|
|
||||||
|
# SAVE_HISTORY (history persistence): when enabled, the valid will
|
||||||
|
# transactions are saved into the wallet's LOCAL history (the History
|
||||||
|
# tab) after every check, each with a configurable label. Default ON.
|
||||||
|
self.SAVE_HISTORY = BalConfig(config, "bal_save_history", True)
|
||||||
|
|
||||||
|
# HISTORY_LABEL: label text applied to the will transactions saved into
|
||||||
|
# the wallet's local history. May contain the "{willexecutor}" token,
|
||||||
|
# which is replaced with the will-executor URL of each will item at
|
||||||
|
# save time.
|
||||||
|
self.HISTORY_LABEL = BalConfig(
|
||||||
|
config,
|
||||||
|
"bal_history_label",
|
||||||
|
"BitcoinAfterLife inheritance transaction - {willexecutor}",
|
||||||
|
)
|
||||||
|
|
||||||
# AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
|
# AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
|
||||||
# querying the will-executor servers, automatically sign the will
|
# querying the will-executor servers, automatically sign the will
|
||||||
# transactions and broadcast them to their will-executors, without the
|
# transactions and broadcast them to their will-executors, without the
|
||||||
@@ -477,14 +492,14 @@ class BalTimestamp:
|
|||||||
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
|
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
|
||||||
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
|
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
|
||||||
"""
|
"""
|
||||||
INT32_MAX = 2 ** 31 - 1
|
int32_max = 2 ** 31 - 1
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(ts)
|
return datetime.fromtimestamp(ts)
|
||||||
except (OSError, OverflowError, ValueError):
|
except (OSError, OverflowError, ValueError):
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(min(int(ts), INT32_MAX))
|
return datetime.fromtimestamp(min(int(ts), int32_max))
|
||||||
except (OSError, OverflowError, ValueError):
|
except (OSError, OverflowError, ValueError):
|
||||||
return datetime.fromtimestamp(INT32_MAX)
|
return datetime.fromtimestamp(int32_max)
|
||||||
|
|
||||||
def to_date(self, from_date=None, reverse=False):
|
def to_date(self, from_date=None, reverse=False):
|
||||||
"""Resolve to a ``datetime``.
|
"""Resolve to a ``datetime``.
|
||||||
|
|||||||
107
bal/core/util.py
107
bal/core/util.py
@@ -20,6 +20,7 @@ original implementation.
|
|||||||
import bisect
|
import bisect
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
from electrum.transaction import PartialTxOutput
|
from electrum.transaction import PartialTxOutput
|
||||||
|
|
||||||
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
||||||
@@ -400,14 +401,14 @@ class Util:
|
|||||||
def get_lowest_valid_tx(available_utxos, will):
|
def get_lowest_valid_tx(available_utxos, will):
|
||||||
"""Placeholder kept from the original code (sorts the will by locktime)."""
|
"""Placeholder kept from the original code (sorts the will by locktime)."""
|
||||||
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
|
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
|
||||||
for txid, willitem in will.items():
|
for _txid, _willitem in will.items():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_locktimes(will):
|
def get_locktimes(will):
|
||||||
"""Return the distinct locktimes used by the transactions in ``will``."""
|
"""Return the distinct locktimes used by the transactions in ``will``."""
|
||||||
locktimes = {}
|
locktimes = {}
|
||||||
for txid, willitem in will.items():
|
for _, willitem in will.items():
|
||||||
locktimes[willitem["tx"].locktime] = True
|
locktimes[willitem["tx"].locktime] = True
|
||||||
return locktimes.keys()
|
return locktimes.keys()
|
||||||
|
|
||||||
@@ -446,7 +447,7 @@ class Util:
|
|||||||
def get_will_spent_utxos(will):
|
def get_will_spent_utxos(will):
|
||||||
"""Collect every input spent by any transaction in ``will``."""
|
"""Collect every input spent by any transaction in ``will``."""
|
||||||
utxos = []
|
utxos = []
|
||||||
for txid, willitem in will.items():
|
for _, willitem in will.items():
|
||||||
utxos += willitem["tx"].inputs()
|
utxos += willitem["tx"].inputs()
|
||||||
|
|
||||||
return utxos
|
return utxos
|
||||||
@@ -493,6 +494,106 @@ class Util:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_available_utxos(wallet, history_label, will_locktime=None):
|
||||||
|
"""Return the wallet's UTXOs as seen by the plugin's flows.
|
||||||
|
|
||||||
|
``wallet.get_utxos()`` drops any output that a wallet-LOCAL transaction
|
||||||
|
marks as spent. The plugin itself creates such local spenders when it
|
||||||
|
saves an incomplete will transaction into the local history; a *later*
|
||||||
|
will transaction stored there (a replacement/future will with a locktime
|
||||||
|
strictly after ``will_locktime``) must not hide the coins from the will
|
||||||
|
being checked or rebuilt. This view therefore restores those coins.
|
||||||
|
|
||||||
|
A local spender is ignored (the coin is kept available) only when ALL of
|
||||||
|
these hold:
|
||||||
|
|
||||||
|
* it is a wallet-local or future transaction (not broadcast),
|
||||||
|
* its wallet label matches the BAL history label template (after the
|
||||||
|
"{willexecutor}" substitution),
|
||||||
|
* the stored spender's locktime is strictly LATER than ``will_locktime``.
|
||||||
|
|
||||||
|
Real (broadcast/confirmed) spenders are never ignored. With a falsy
|
||||||
|
``will_locktime`` this returns ``wallet.get_utxos()`` unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wallet: The Electrum wallet object.
|
||||||
|
history_label: The BAL history label template (may contain
|
||||||
|
"{willexecutor}").
|
||||||
|
will_locktime: Reference locktime of the will being operated on.
|
||||||
|
"""
|
||||||
|
if not wallet or not will_locktime:
|
||||||
|
return list(wallet.get_utxos()) if wallet else []
|
||||||
|
adb = getattr(wallet, "adb", None)
|
||||||
|
if adb is None or not hasattr(adb, "get_addr_outputs"):
|
||||||
|
return list(wallet.get_utxos())
|
||||||
|
addresses = (
|
||||||
|
wallet.get_addresses() if hasattr(wallet, "get_addresses") else []
|
||||||
|
)
|
||||||
|
utxos = []
|
||||||
|
for addr in addresses:
|
||||||
|
try:
|
||||||
|
outputs = adb.get_addr_outputs(addr)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for utxo in outputs.values():
|
||||||
|
if utxo.spent_height is None:
|
||||||
|
utxos.append(utxo)
|
||||||
|
continue
|
||||||
|
spender = getattr(utxo, "spent_txid", None)
|
||||||
|
if spender and Util._is_ignorable_local_spender(
|
||||||
|
wallet, spender, history_label, will_locktime
|
||||||
|
):
|
||||||
|
utxos.append(utxo)
|
||||||
|
return utxos
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_ignorable_local_spender(wallet, spender, history_label, will_locktime):
|
||||||
|
"""True when the local ``spender`` tx is a later BAL history will tx.
|
||||||
|
|
||||||
|
See ``get_available_utxos`` for the exact conditions. Defensive: any
|
||||||
|
lookup failure makes this return False, so a spender is never ignored
|
||||||
|
on uncertain data.
|
||||||
|
"""
|
||||||
|
adb = wallet.adb
|
||||||
|
try:
|
||||||
|
height = int(adb.get_tx_height(spender).height())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
label = wallet.get_label_for_txid(spender)
|
||||||
|
except Exception:
|
||||||
|
label = None
|
||||||
|
if not label or not Util._label_matches_history(label, history_label):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
stored = adb.db.get_transaction(spender)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if stored is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(stored.locktime) > int(will_locktime)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _label_matches_history(label, history_label):
|
||||||
|
"""True when ``label`` is the ``history_label`` template with the
|
||||||
|
"{willexecutor}" token substituted by some (possibly empty) executor URL.
|
||||||
|
"""
|
||||||
|
token = "{willexecutor}"
|
||||||
|
if token in history_label:
|
||||||
|
prefix, suffix = history_label.split(token, 1)
|
||||||
|
return (
|
||||||
|
label.startswith(prefix)
|
||||||
|
and label.endswith(suffix)
|
||||||
|
and len(label) >= len(prefix) + len(suffix)
|
||||||
|
)
|
||||||
|
return label == history_label
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def cmp_output(outputa, outputb):
|
def cmp_output(outputa, outputb):
|
||||||
"""Two outputs are equal when both address and value match."""
|
"""Two outputs are equal when both address and value match."""
|
||||||
|
|||||||
318
bal/core/will.py
318
bal/core/will.py
@@ -40,12 +40,13 @@ from electrum.transaction import (
|
|||||||
tx_from_any,
|
tx_from_any,
|
||||||
)
|
)
|
||||||
from electrum.util import (
|
from electrum.util import (
|
||||||
|
UnrelatedTransactionException,
|
||||||
bfh,
|
bfh,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .heirs import WillExecutorFeeTooHighException
|
||||||
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
|
||||||
@@ -220,13 +221,8 @@ class Will:
|
|||||||
if ow.we["url"] == nw.we["url"]:
|
if ow.we["url"] == nw.we["url"]:
|
||||||
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
|
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
|
||||||
return anticipate
|
return anticipate
|
||||||
else:
|
elif int(ow.tx_fees) != int(nw.tx_fees):
|
||||||
if int(ow.tx_fees) != int(nw.tx_fees):
|
|
||||||
return anticipate
|
return anticipate
|
||||||
else:
|
|
||||||
ow.tx.locktime
|
|
||||||
else:
|
|
||||||
ow.tx.locktime
|
|
||||||
else:
|
else:
|
||||||
if nw.we == ow.we:
|
if nw.we == ow.we:
|
||||||
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
|
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
|
||||||
@@ -456,10 +452,14 @@ class Will:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def invalidate_will(will, wallet, fees_per_byte):
|
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
|
||||||
|
will_locktime=None):
|
||||||
print("invalidate tx in will module")
|
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)
|
||||||
|
if history_label is not None and will_locktime is not None:
|
||||||
|
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
|
||||||
|
else:
|
||||||
utxos = wallet.get_utxos()
|
utxos = wallet.get_utxos()
|
||||||
filtered_inputs = []
|
filtered_inputs = []
|
||||||
prevout_to_spend = []
|
prevout_to_spend = []
|
||||||
@@ -512,7 +512,7 @@ class Will:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_new(will):
|
def is_new(will):
|
||||||
for wid, w in will.items():
|
for _wid, w in will.items():
|
||||||
if w.get_status("VALID") and not w.get_status("COMPLETE"):
|
if w.get_status("VALID") and not w.get_status("COMPLETE"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -538,10 +538,26 @@ class Will:
|
|||||||
wi.set_status("INVALIDATED", True)
|
wi.set_status("INVALIDATED", True)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if wallet.db.get_transaction(wi._id):
|
# The funding outpoint is not part of the will tree:
|
||||||
wi.set_status("CONFIRMED", True)
|
# decide from whether a broadcast transaction really
|
||||||
else:
|
# spends it (a wallet-local history copy of the same
|
||||||
|
# will tx must neither turn the item CONFIRMED nor
|
||||||
|
# INVALIDATED - it is just a persistence artifact).
|
||||||
|
stored = None
|
||||||
|
if wallet and getattr(wallet, "db", None):
|
||||||
|
try:
|
||||||
|
stored = wallet.db.get_transaction(wi._id)
|
||||||
|
except Exception:
|
||||||
|
stored = None
|
||||||
|
spender_height = Will._funding_spender_height(wallet, inp)
|
||||||
|
if spender_height is None:
|
||||||
|
if stored:
|
||||||
|
continue
|
||||||
wi.set_status("INVALIDATED", True)
|
wi.set_status("INVALIDATED", True)
|
||||||
|
elif spender_height == 0:
|
||||||
|
wi.set_status("MEMPOOL", True)
|
||||||
|
else:
|
||||||
|
wi.set_status("CONFIRMED", True)
|
||||||
|
|
||||||
for child in wi.search(all_inputs):
|
for child in wi.search(all_inputs):
|
||||||
if child.tx.locktime < wi.tx.locktime:
|
if child.tx.locktime < wi.tx.locktime:
|
||||||
@@ -579,9 +595,17 @@ class Will:
|
|||||||
for inp in w.tx.inputs():
|
for inp in w.tx.inputs():
|
||||||
inp_str = Util.utxo_to_str(inp)
|
inp_str = Util.utxo_to_str(inp)
|
||||||
if inp_str not in utxos_list:
|
if inp_str not in utxos_list:
|
||||||
if wallet:
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
|
continue
|
||||||
height = Will.check_tx_height(w.tx, wallet)
|
height = Will.check_tx_height(w.tx, wallet)
|
||||||
if height < 0:
|
if height < 0:
|
||||||
|
# The will tx itself is not on-chain. A missing
|
||||||
|
# funding UTXO is only a real problem when a
|
||||||
|
# broadcast transaction actually spends it; a
|
||||||
|
# wallet-local (history) copy of the same will tx
|
||||||
|
# marks the funding spent locally and must not
|
||||||
|
# invalidate the will.
|
||||||
|
if Will._funding_really_spent(wallet, inp_str):
|
||||||
Will.set_invalidate(wid, willtree)
|
Will.set_invalidate(wid, willtree)
|
||||||
elif height == 0:
|
elif height == 0:
|
||||||
w.set_status("MEMPOOL", True)
|
w.set_status("MEMPOOL", True)
|
||||||
@@ -628,6 +652,83 @@ class Will:
|
|||||||
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _funding_really_spent(wallet, inp_str):
|
||||||
|
"""True when a broadcast transaction really spends the ``txid:n`` outpoint.
|
||||||
|
|
||||||
|
``wallet.adb.get_spender`` discards wallet-local spenders (the stored
|
||||||
|
will tx from the local history) and future transactions, so this is True
|
||||||
|
only when the funding was consumed by a real on-chain/mempool tx.
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return wallet.adb.get_spender(inp_str) is not None
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"get_spender failed for {inp_str}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _funding_spender_height(wallet, inp_str):
|
||||||
|
"""Mined height of the broadcast tx spending ``inp_str``, or None.
|
||||||
|
|
||||||
|
Returns ``None`` when no broadcast transaction spends the outpoint (a
|
||||||
|
wallet-local history spender or a future tx are ignored by
|
||||||
|
``adb.get_spender``). The height is 0 for a mempool spender and positive
|
||||||
|
for a confirmed one.
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
spender = wallet.adb.get_spender(inp_str)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"get_spender failed for {inp_str}: {e}")
|
||||||
|
return None
|
||||||
|
if spender is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(wallet.adb.get_tx_height(spender).height())
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"get_tx_height failed for {spender}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _absorb_history_signatures(will, wallet):
|
||||||
|
"""Merge signatures from the wallet's stored local copy of each will tx.
|
||||||
|
|
||||||
|
An incomplete will transaction saved into the local history (see
|
||||||
|
``save_valid_transactions_to_history``) may later accumulate signatures
|
||||||
|
(e.g. after a manual merge from a more complete copy). The in-memory
|
||||||
|
will item would otherwise miss those signatures on the next check. For
|
||||||
|
every item whose stored wallet copy is the same partial transaction the
|
||||||
|
signatures are merged into the in-memory one and, if it becomes fully
|
||||||
|
signed, the item is marked COMPLETE.
|
||||||
|
|
||||||
|
This method must never raise: history absorption is a convenience on top
|
||||||
|
of the will check, so any failure is logged and ignored.
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "db", None):
|
||||||
|
return
|
||||||
|
for wi in will.values():
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
wi.tx is None
|
||||||
|
or not isinstance(wi.tx, PartialTransaction)
|
||||||
|
or wi.tx.is_complete()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
stored = wallet.db.get_transaction(wi._id)
|
||||||
|
if (
|
||||||
|
not isinstance(stored, Transaction)
|
||||||
|
or stored.txid() != wi.tx.txid()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
wi.tx.combine_with_other_psbt(stored)
|
||||||
|
if wi.tx.is_complete():
|
||||||
|
wi.set_status("COMPLETE", True)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"absorb history signatures failed for item {wi._id}: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_will(will, all_utxos, wallet, timestamp_to_check):
|
def check_will(will, all_utxos, wallet, timestamp_to_check):
|
||||||
"""Validate a will against the current wallet state.
|
"""Validate a will against the current wallet state.
|
||||||
@@ -643,6 +744,7 @@ class Will:
|
|||||||
timestamp_to_check: The reference UNIX timestamp (usually "now")
|
timestamp_to_check: The reference UNIX timestamp (usually "now")
|
||||||
used to decide whether any transaction has expired.
|
used to decide whether any transaction has expired.
|
||||||
"""
|
"""
|
||||||
|
Will._absorb_history_signatures(will, wallet)
|
||||||
Will.add_willtree(will)
|
Will.add_willtree(will)
|
||||||
utxos_list = Will.utxos_strs(all_utxos)
|
utxos_list = Will.utxos_strs(all_utxos)
|
||||||
|
|
||||||
@@ -656,6 +758,186 @@ class Will:
|
|||||||
|
|
||||||
Will.search_rai(all_inputs, all_utxos, will, wallet)
|
Will.search_rai(all_inputs, all_utxos, will, wallet)
|
||||||
|
|
||||||
|
Will.check_signatures(will, wallet)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save_valid_transactions_to_history(will, wallet, history_label):
|
||||||
|
"""Keep the wallet's LOCAL history in sync with the current will state.
|
||||||
|
|
||||||
|
Called after the will has been built/signed/checked (see the
|
||||||
|
SAVE_HISTORY / HISTORY_LABEL settings). A will transaction belongs in the
|
||||||
|
local history while it is still "New" (not yet fully signed - i.e. an
|
||||||
|
incomplete partial transaction), and must be removed once it becomes
|
||||||
|
"Complete" (fully signed), because at that point it is ready to be
|
||||||
|
broadcast and will appear in the history on its own.
|
||||||
|
|
||||||
|
For every will item that is valid and whose transaction has a txid it:
|
||||||
|
|
||||||
|
1. decodes the label template, replacing "{willexecutor}" with the
|
||||||
|
will-executor URL of the item,
|
||||||
|
2. if the transaction is NOT complete, stores it via
|
||||||
|
``wallet.adb.add_transaction`` (merging signatures when an
|
||||||
|
already-stored partial transaction is upgraded by a more complete
|
||||||
|
one) and tags it with the decoded label,
|
||||||
|
3. if the transaction IS complete, does not store it: its matching
|
||||||
|
local-history entry is removed by the cleanup below.
|
||||||
|
|
||||||
|
Finally it deletes every wallet-local transaction whose label exactly
|
||||||
|
matches the decoded label of a current valid item but that is no longer
|
||||||
|
among the just-saved transactions, so fully-signed, rebuilt or replaced
|
||||||
|
wills do not pile up stale entries.
|
||||||
|
|
||||||
|
This method must never raise: history persistence is a convenience on
|
||||||
|
top of the will check, so any failure is logged and ignored.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
will: The will dictionary (WillItem entries keyed by txid).
|
||||||
|
wallet: The Electrum wallet object (may be falsy for offline
|
||||||
|
checks, in which case this is a no-op).
|
||||||
|
history_label: The label template to apply (may contain
|
||||||
|
"{willexecutor}").
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
|
return
|
||||||
|
saved_txids = []
|
||||||
|
try:
|
||||||
|
current_labels = {
|
||||||
|
history_label.replace(
|
||||||
|
"{willexecutor}", (wi.we or {}).get("url", "")
|
||||||
|
)
|
||||||
|
for wi in will.values()
|
||||||
|
if wi.get_status("VALID")
|
||||||
|
and wi.tx is not None
|
||||||
|
and wi.tx.txid() is not None
|
||||||
|
}
|
||||||
|
for wi in will.values():
|
||||||
|
if not wi.get_status("VALID"):
|
||||||
|
continue
|
||||||
|
if wi.tx is None or wi.tx.txid() is None:
|
||||||
|
continue
|
||||||
|
# Fully-signed (complete) transactions must NOT be saved: they
|
||||||
|
# are removed from the local history so the list does not show a
|
||||||
|
# placeholder for a transaction that will appear on its own once
|
||||||
|
# broadcast/confirmed. Only the not-yet-complete "New" items are
|
||||||
|
# stored. Note that fully-segwit partial txs have a txid even
|
||||||
|
# when incomplete, so the txid() check alone is not enough.
|
||||||
|
if wi.tx.is_complete():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
txid = wi.tx.txid()
|
||||||
|
label = history_label.replace(
|
||||||
|
"{willexecutor}", (wi.we or {}).get("url", "")
|
||||||
|
)
|
||||||
|
Will._add_transaction_to_history(wallet, wi.tx, txid)
|
||||||
|
try:
|
||||||
|
wallet.set_label(txid, label)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"set_label failed for {txid}: {e}")
|
||||||
|
saved_txids.append(txid)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"save to history failed for item {wi._id}: {e}")
|
||||||
|
# Delete stale wallet-local txs whose label matches a current valid
|
||||||
|
# item but that are no longer among the saved ones. This removes
|
||||||
|
# entries for fully-signed (complete) items and for rebuilt/replaced
|
||||||
|
# wills with the same executor.
|
||||||
|
for txid, label in Will._wallet_labels(wallet):
|
||||||
|
if txid in saved_txids:
|
||||||
|
continue
|
||||||
|
if label not in current_labels:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wallet.adb.remove_transaction(txid)
|
||||||
|
try:
|
||||||
|
wallet.set_label(txid, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"remove from history failed for {txid}: {e}")
|
||||||
|
try:
|
||||||
|
wallet.save_db()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"save_db failed after history update: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"save_valid_transactions_to_history failed: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_transaction_to_history(wallet, tx, txid):
|
||||||
|
"""Store *tx* into the wallet's local history via ``adb``.
|
||||||
|
|
||||||
|
If a partial transaction with the same txid is already stored and *tx*
|
||||||
|
carries additional signatures, the signatures are merged into the stored
|
||||||
|
one before saving. ``allow_unrelated`` is retried as a fallback so that
|
||||||
|
self-created txs (which are not yet part of the wallet's UTXO set) are
|
||||||
|
still accepted.
|
||||||
|
"""
|
||||||
|
adb = wallet.adb
|
||||||
|
existing = None
|
||||||
|
try:
|
||||||
|
existing = wallet.db.get_transaction(txid)
|
||||||
|
except Exception:
|
||||||
|
existing = None
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
isinstance(existing, PartialTransaction)
|
||||||
|
and not existing.is_complete()
|
||||||
|
and isinstance(tx, PartialTransaction)
|
||||||
|
):
|
||||||
|
existing.combine_with_other_psbt(tx)
|
||||||
|
adb.add_transaction(existing)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
adb.add_transaction(tx)
|
||||||
|
except UnrelatedTransactionException:
|
||||||
|
adb.add_transaction(tx, allow_unrelated=True)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"add_transaction failed for {txid}: {e}") from e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wallet_labels(wallet):
|
||||||
|
"""Return the wallet's ``(txid, label)`` pairs in a defensive way."""
|
||||||
|
try:
|
||||||
|
get_all_labels = wallet.get_all_labels
|
||||||
|
except AttributeError:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(get_all_labels().items())
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"get_all_labels failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_signatures(will, wallet=None):
|
||||||
|
"""Refresh the per-item signature counts and the PARTIALLY_SIGNED status.
|
||||||
|
|
||||||
|
The signature counts are derived from the transaction itself via
|
||||||
|
Electrum's ``signature_count()``, which needs a script descriptor on
|
||||||
|
each input (attached from the wallet when available). Items that already
|
||||||
|
carry their own descriptors (e.g. imported/merged partial transactions)
|
||||||
|
are counted even without a wallet.
|
||||||
|
|
||||||
|
An item with at least one signature present but fewer than required is
|
||||||
|
marked PARTIALLY_SIGNED. Items that are already signed (COMPLETE) or
|
||||||
|
whose transaction is complete always clear the flag.
|
||||||
|
"""
|
||||||
|
for wi in will.values():
|
||||||
|
try:
|
||||||
|
if wi.get_status("COMPLETE") or wi.tx is None or wi.tx.is_complete():
|
||||||
|
wi.set_status("PARTIALLY_SIGNED", False)
|
||||||
|
continue
|
||||||
|
if wallet:
|
||||||
|
wi.tx.add_info_from_wallet(wallet)
|
||||||
|
if not hasattr(wi.tx, "signature_count"):
|
||||||
|
continue
|
||||||
|
have, required = wi.tx.signature_count()
|
||||||
|
wi.sigs_have = int(have)
|
||||||
|
wi.sigs_required = int(required)
|
||||||
|
if required > 1 and 0 < have < required:
|
||||||
|
wi.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
else:
|
||||||
|
wi.set_status("PARTIALLY_SIGNED", False)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"check_signatures failed for item {wi._id}: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_min_locktime(will,default_value=None):
|
def get_min_locktime(will,default_value=None):
|
||||||
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
|
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
|
||||||
@@ -784,7 +1066,7 @@ class Will:
|
|||||||
timestamp_to_check: Reference UNIX timestamp (usually "now").
|
timestamp_to_check: Reference UNIX timestamp (usually "now").
|
||||||
"""
|
"""
|
||||||
_logger.info("check if some transaction is expired")
|
_logger.info("check if some transaction is expired")
|
||||||
for prevout_str, wid in all_inputs_min_locktime.items():
|
for _inputs, wid in all_inputs_min_locktime.items():
|
||||||
for w in wid:
|
for w in wid:
|
||||||
if w[1].get_status("VALID"):
|
if w[1].get_status("VALID"):
|
||||||
locktime = int(wid[0][1].tx.locktime)
|
locktime = int(wid[0][1].tx.locktime)
|
||||||
@@ -981,6 +1263,7 @@ class WillItem(Logger):
|
|||||||
"MEMPOOL": ["Mempool", False],
|
"MEMPOOL": ["Mempool", False],
|
||||||
"PUSH_FAIL": ["Push failed", False],
|
"PUSH_FAIL": ["Push failed", False],
|
||||||
"PUSHED": ["Pushed", False],
|
"PUSHED": ["Pushed", False],
|
||||||
|
"PARTIALLY_SIGNED": ["Partially Signed", False],
|
||||||
"REPLACED": ["Replaced", False],
|
"REPLACED": ["Replaced", False],
|
||||||
"RESTORED": ["Restored", False],
|
"RESTORED": ["Restored", False],
|
||||||
"UPDATED": ["Updated", False],
|
"UPDATED": ["Updated", False],
|
||||||
@@ -1039,6 +1322,9 @@ class WillItem(Logger):
|
|||||||
self.STATUS["PUSHED"][1] = True
|
self.STATUS["PUSHED"][1] = True
|
||||||
self.STATUS["PUSH_FAIL"][1] = False
|
self.STATUS["PUSH_FAIL"][1] = False
|
||||||
|
|
||||||
|
if status in ["COMPLETE"]:
|
||||||
|
self.STATUS["PARTIALLY_SIGNED"][1] = False
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def get_status(self, status):
|
def get_status(self, status):
|
||||||
@@ -1059,6 +1345,8 @@ class WillItem(Logger):
|
|||||||
self.time = w.get("time", None)
|
self.time = w.get("time", None)
|
||||||
self.change = w.get("change", None)
|
self.change = w.get("change", None)
|
||||||
self.tx_fees = w.get("baltx_fees", 0)
|
self.tx_fees = w.get("baltx_fees", 0)
|
||||||
|
self.sigs_required = int(w.get("sigs_required", 0))
|
||||||
|
self.sigs_have = int(w.get("sigs_have", 0))
|
||||||
self.father = w.get("Father", None)
|
self.father = w.get("Father", None)
|
||||||
self.children = w.get("Children", None)
|
self.children = w.get("Children", None)
|
||||||
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||||
@@ -1095,6 +1383,8 @@ class WillItem(Logger):
|
|||||||
"time": self.time,
|
"time": self.time,
|
||||||
"change": self.change,
|
"change": self.change,
|
||||||
"baltx_fees": self.tx_fees,
|
"baltx_fees": self.tx_fees,
|
||||||
|
"sigs_required": self.sigs_required,
|
||||||
|
"sigs_have": self.sigs_have,
|
||||||
}
|
}
|
||||||
for key in self.STATUS:
|
for key in self.STATUS:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ class Willexecutors:
|
|||||||
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":
|
||||||
raise Willexecutors.AlreadyPresentException()
|
raise Willexecutors.AlreadyPresentException() from None
|
||||||
out = False
|
out = False
|
||||||
willexecutor["broadcast_status"] = _("Failed")
|
willexecutor["broadcast_status"] = _("Failed")
|
||||||
|
|
||||||
@@ -485,8 +485,7 @@ class Willexecutors:
|
|||||||
Returns:
|
Returns:
|
||||||
The same ``willexecutors`` mapping, updated in place.
|
The same ``willexecutors`` mapping, updated in place.
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import ThreadPoolExecutor, wait
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
from concurrent.futures import FIRST_COMPLETED
|
|
||||||
|
|
||||||
items = list(willexecutors.items())
|
items = list(willexecutors.items())
|
||||||
if not items:
|
if not items:
|
||||||
@@ -566,8 +565,7 @@ class Willexecutors:
|
|||||||
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
||||||
answered in time (timed-out servers are reported via ``on_timeout``).
|
answered in time (timed-out servers are reported via ``on_timeout``).
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import ThreadPoolExecutor, wait
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
from concurrent.futures import FIRST_COMPLETED
|
|
||||||
|
|
||||||
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
||||||
results = {}
|
results = {}
|
||||||
@@ -684,8 +682,7 @@ class Willexecutors:
|
|||||||
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
||||||
that answered in time.
|
that answered in time.
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import ThreadPoolExecutor, wait
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
from concurrent.futures import FIRST_COMPLETED
|
|
||||||
|
|
||||||
targets = [(wid, url) for wid, url in items if url]
|
targets = [(wid, url) for wid, url in items if url]
|
||||||
results = {}
|
results = {}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ to "check in" before the locktime expires. This module turns the event data
|
|||||||
into an RFC-5545 .ics file and opens it with the OS default application.
|
into an RFC-5545 .ics file and opens it with the OS default application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .common import *
|
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import QToolButton
|
from PyQt6.QtWidgets import QToolButton
|
||||||
|
|
||||||
|
from .common import *
|
||||||
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
|
|
||||||
|
|
||||||
class BalCalendarButton(QToolButton):
|
class BalCalendarButton(QToolButton):
|
||||||
"""A QToolButton with a dropdown menu for .ics calendar file actions.
|
"""A QToolButton with a dropdown menu for .ics calendar file actions.
|
||||||
@@ -79,7 +80,8 @@ class BalCalendarButton(QToolButton):
|
|||||||
path = self._ensure_ics()
|
path = self._ensure_ics()
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
import shlex, subprocess
|
import shlex
|
||||||
|
import subprocess
|
||||||
if self._bal_window.bal_plugin.is_basic_mode():
|
if self._bal_window.bal_plugin.is_basic_mode():
|
||||||
app = self._bal_window.bal_plugin.CALENDAR_APP.default
|
app = self._bal_window.bal_plugin.CALENDAR_APP.default
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -27,65 +27,135 @@ from decimal import Decimal
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any, Callable, Mapping, Optional, Union
|
from typing import Any, Callable, Mapping, Optional, Union
|
||||||
|
|
||||||
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX,
|
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||||
NLOCKTIME_MIN)
|
|
||||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||||
from electrum.gui.qt.my_treeview import MyTreeView
|
from electrum.gui.qt.my_treeview import MyTreeView
|
||||||
from electrum.gui.qt.password_dialog import PasswordDialog
|
from electrum.gui.qt.password_dialog import PasswordDialog
|
||||||
from electrum.gui.qt.transaction_dialog import TxDialog
|
from electrum.gui.qt.transaction_dialog import TxDialog
|
||||||
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
|
from electrum.gui.qt.util import (
|
||||||
EnterButton, HelpButton, MessageBoxMixin,
|
Buttons,
|
||||||
OkButton, TaskThread, WindowModalDialog,
|
CancelButton,
|
||||||
char_width_in_lineedit, getSaveFileName,
|
ColorScheme,
|
||||||
import_meta_gui, read_QIcon_from_bytes,
|
EnterButton,
|
||||||
read_QPixmap_from_bytes, webopen)
|
HelpButton,
|
||||||
|
MessageBoxMixin,
|
||||||
|
OkButton,
|
||||||
|
TaskThread,
|
||||||
|
WindowModalDialog,
|
||||||
|
char_width_in_lineedit,
|
||||||
|
getSaveFileName,
|
||||||
|
import_meta_gui,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
read_QPixmap_from_bytes,
|
||||||
|
webopen,
|
||||||
|
)
|
||||||
from electrum.i18n import _
|
from electrum.i18n import _
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
||||||
from electrum.payment_identifier import PaymentIdentifier
|
from electrum.payment_identifier import PaymentIdentifier
|
||||||
from electrum.plugin import hook
|
from electrum.plugin import hook
|
||||||
from electrum.transaction import SerializationError, Transaction, tx_from_any
|
from electrum.transaction import SerializationError, Transaction, tx_from_any
|
||||||
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
|
from electrum.util import (
|
||||||
decimal_point_to_base_unit_name, read_json_file,
|
DECIMAL_POINT,
|
||||||
write_json_file)
|
FileExportFailed,
|
||||||
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize,
|
FileImportFailed,
|
||||||
Qt, QTimer, pyqtSignal)
|
UserCancelled,
|
||||||
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
|
decimal_point_to_base_unit_name,
|
||||||
QStandardItemModel)
|
read_json_file,
|
||||||
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
|
write_json_file,
|
||||||
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
|
)
|
||||||
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu,
|
from PyQt6.QtCore import (
|
||||||
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
|
QDateTime,
|
||||||
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame,
|
QModelIndex,
|
||||||
QVBoxLayout, QWidget, QDialog)
|
QPersistentModelIndex,
|
||||||
|
QSize,
|
||||||
|
Qt,
|
||||||
|
QTimer,
|
||||||
|
pyqtSignal,
|
||||||
|
)
|
||||||
|
from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItemModel
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QAbstractSpinBox,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QDateTimeEdit,
|
||||||
|
QDialog,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMenu,
|
||||||
|
QMenuBar,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStackedWidget,
|
||||||
|
QStyle,
|
||||||
|
QStyleOptionFrame,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...core.heirs import (
|
||||||
|
HEIR_DUST_AMOUNT,
|
||||||
|
HEIR_REAL_AMOUNT,
|
||||||
|
OP_RETURN_PREFIX,
|
||||||
|
HeirAmountIsDustException,
|
||||||
|
Heirs,
|
||||||
|
WillExecutorFeeTooHighException,
|
||||||
|
get_op_return_hex,
|
||||||
|
is_op_return_address,
|
||||||
|
validate_op_return_hex,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Core (GUI-free) logic layer ---
|
# --- 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,
|
|
||||||
HeirAmountIsDustException, Heirs,
|
|
||||||
OP_RETURN_PREFIX, is_op_return_address,
|
|
||||||
get_op_return_hex, validate_op_return_hex,
|
|
||||||
WillExecutorFeeTooHighException)
|
|
||||||
from ...core.util import Util
|
from ...core.util import Util
|
||||||
from ...core.will import (AmountException, HeirChangeException,
|
from ...core.will import (
|
||||||
HeirNotFoundException, NoHeirsException,
|
AmountException,
|
||||||
NotCompleteWillException, NoWillExecutorNotPresent,
|
HeirChangeException,
|
||||||
TxFeesChangedException, Will,
|
HeirNotFoundException,
|
||||||
WillexecutorChangeException, WillExecutorNotPresent,
|
NoHeirsException,
|
||||||
WillExpiredException, WillItem, WillPostponedException)
|
NotCompleteWillException,
|
||||||
from ...core.willexecutors import Willexecutors
|
NoWillExecutorNotPresent,
|
||||||
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401
|
TxFeesChangedException,
|
||||||
|
Will,
|
||||||
|
WillexecutorChangeException,
|
||||||
|
WillExecutorNotPresent,
|
||||||
|
WillExpiredException,
|
||||||
|
WillItem,
|
||||||
|
WillPostponedException,
|
||||||
|
)
|
||||||
|
from ...core.willexecutors import ( # noqa: F401
|
||||||
|
Willexecutors,
|
||||||
|
is_onion_url,
|
||||||
|
is_tor_active,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Presentation helpers ---
|
# --- Presentation helpers ---
|
||||||
from .theme import server_status_text, server_status_tooltip, status_color
|
from .theme import (
|
||||||
from .window_utils import (bring_to_front, show_modal, show_on_top,
|
server_status_text,
|
||||||
stop_thread, top_level_of)
|
server_status_tooltip,
|
||||||
|
signature_suffix,
|
||||||
|
status_color,
|
||||||
|
)
|
||||||
|
from .window_utils import (
|
||||||
|
bring_to_front,
|
||||||
|
show_modal,
|
||||||
|
show_on_top,
|
||||||
|
stop_thread,
|
||||||
|
top_level_of,
|
||||||
|
)
|
||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class shown_cv:
|
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
||||||
_type = bool
|
_type = bool
|
||||||
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ the few list classes they reference are imported lazily inside the methods that
|
|||||||
use them (see ``lists`` imports below).
|
use them (see ``lists`` imports below).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .calendar import BalCalendar, BalCalendarButton
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget,
|
from .widgets import (
|
||||||
LockTimeWidget, PercAmountEdit, ThresholdTimeWidget,
|
WillSettingsWidget,
|
||||||
WillSettingsWidget, WillWidget, basic_reminder_offsets,
|
WillWidget,
|
||||||
compute_reminder_offsets)
|
basic_reminder_offsets,
|
||||||
from .calendar import BalCalendar, BalCalendarButton
|
compute_reminder_offsets,
|
||||||
|
)
|
||||||
|
|
||||||
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
|
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
|
||||||
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
||||||
|
|
||||||
@@ -31,9 +34,7 @@ from .calendar import BalCalendar, BalCalendarButton
|
|||||||
class BalDialog(QDialog,MessageBoxMixin):
|
class BalDialog(QDialog,MessageBoxMixin):
|
||||||
_stopping = False
|
_stopping = False
|
||||||
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
|
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
|
||||||
import signal
|
|
||||||
from PyQt6.QtCore import QMetaObject, Qt
|
from PyQt6.QtCore import QMetaObject, Qt
|
||||||
from PyQt6.QtWidgets import QApplication
|
|
||||||
def handler(signum, frame):
|
def handler(signum, frame):
|
||||||
QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection)
|
QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
@@ -624,7 +625,12 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
except CheckAliveError as cae:
|
except CheckAliveError as cae:
|
||||||
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
|
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
|
||||||
tx = Will.invalidate_will(
|
tx = Will.invalidate_will(
|
||||||
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
|
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte,
|
||||||
|
history_label=self.bal_window.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
will_locktime=Will.get_min_locktime(
|
||||||
|
self.bal_window.willitems,
|
||||||
|
default_value=self.bal_window.date_to_check,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if tx:
|
if tx:
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
@@ -645,7 +651,14 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
Will.check_amounts(
|
Will.check_amounts(
|
||||||
self.bal_window.heirs,
|
self.bal_window.heirs,
|
||||||
self.bal_window.willexecutors,
|
self.bal_window.willexecutors,
|
||||||
self.bal_window.window.wallet.get_utxos(),
|
Util.get_available_utxos(
|
||||||
|
self.bal_window.window.wallet,
|
||||||
|
self.bal_window.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
Will.get_min_locktime(
|
||||||
|
self.bal_window.willitems,
|
||||||
|
default_value=self.bal_window.date_to_check,
|
||||||
|
),
|
||||||
|
),
|
||||||
self.bal_window.date_to_check,
|
self.bal_window.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(),
|
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||||
@@ -699,9 +712,14 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
self.msg_set_checking(_("Postponed: invalidating old will"))
|
self.msg_set_checking(_("Postponed: invalidating old will"))
|
||||||
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
|
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
|
||||||
return None, Will.invalidate_will(
|
return None, Will.invalidate_will(
|
||||||
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
|
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte,
|
||||||
|
history_label=self.bal_window.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
will_locktime=Will.get_min_locktime(
|
||||||
|
self.bal_window.willitems,
|
||||||
|
default_value=self.bal_window.date_to_check,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except NoHeirsException as e:
|
except NoHeirsException:
|
||||||
_logger.debug("no heirs")
|
_logger.debug("no heirs")
|
||||||
self.msg_set_checking("No Heirs")
|
self.msg_set_checking("No Heirs")
|
||||||
except NotCompleteWillException as e:
|
except NotCompleteWillException as e:
|
||||||
@@ -1477,6 +1495,13 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
def on_success_phase2(self, arg=False):
|
def on_success_phase2(self, arg=False):
|
||||||
self.thread.stop()
|
self.thread.stop()
|
||||||
self.bal_window.save_willitems()
|
self.bal_window.save_willitems()
|
||||||
|
# After the whole check/sign/broadcast cycle, keep the wallet's local
|
||||||
|
# history in sync with the current will state (save the still "New"
|
||||||
|
# incomplete txs, remove the now-complete ones).
|
||||||
|
try:
|
||||||
|
self.bal_window._save_will_to_history()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"save_will_to_history after phase2 failed: {e}")
|
||||||
self.msg_edit_row(_("Finished"))
|
self.msg_edit_row(_("Finished"))
|
||||||
# Instead of auto-closing after a countdown, let the user decide when to
|
# Instead of auto-closing after a countdown, let the user decide when to
|
||||||
# dismiss the dialog: they can read the full "Building Will" report at
|
# dismiss the dialog: they can read the full "Building Will" report at
|
||||||
@@ -1969,9 +1994,17 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
|
|
||||||
|
|
||||||
class WillDetailDialog(BalDialog):
|
class WillDetailDialog(BalDialog):
|
||||||
def __init__(self, bal_window):
|
def __init__(self, bal_window, will=None, threshold=None):
|
||||||
|
# ``will``/``threshold`` are passed when showing an IMPORTED (read-only)
|
||||||
self.will = bal_window.willitems
|
# will. In that case every action button below operates on the imported
|
||||||
|
# will, never on the live wallet state.
|
||||||
|
self._external_will = will is not None
|
||||||
|
self.will = will if self._external_will else bal_window.willitems
|
||||||
|
if threshold is not None:
|
||||||
|
self.threshold = threshold
|
||||||
|
elif self._external_will:
|
||||||
|
self.threshold = max(wi.tx.locktime for wi in self.will.values())
|
||||||
|
else:
|
||||||
self.threshold = bal_window.will_settings["real_threshold"]
|
self.threshold = bal_window.will_settings["real_threshold"]
|
||||||
|
|
||||||
self.bal_window = bal_window
|
self.bal_window = bal_window
|
||||||
@@ -2004,8 +2037,14 @@ class WillDetailDialog(BalDialog):
|
|||||||
b.clicked.connect(self.export_will)
|
b.clicked.connect(self.export_will)
|
||||||
hlayout.addWidget(b)
|
hlayout.addWidget(b)
|
||||||
b = QPushButton(_("Invalidate"))
|
b = QPushButton(_("Invalidate"))
|
||||||
b.clicked.connect(bal_window.invalidate_will)
|
b.clicked.connect(self.invalidate_will)
|
||||||
hlayout.addWidget(b)
|
hlayout.addWidget(b)
|
||||||
|
self.merge_button = None
|
||||||
|
if self._external_will:
|
||||||
|
b = QPushButton(_("Merge"))
|
||||||
|
b.clicked.connect(self.merge_will)
|
||||||
|
hlayout.addWidget(b)
|
||||||
|
self.merge_button = b
|
||||||
self.vlayout.addWidget(w)
|
self.vlayout.addWidget(w)
|
||||||
|
|
||||||
self.paint_scroll_area()
|
self.paint_scroll_area()
|
||||||
@@ -2026,22 +2065,48 @@ class WillDetailDialog(BalDialog):
|
|||||||
self.scrollbox = QScrollArea()
|
self.scrollbox = QScrollArea()
|
||||||
viewport = QWidget(self.scrollbox)
|
viewport = QWidget(self.scrollbox)
|
||||||
self.willlayout = QVBoxLayout(viewport)
|
self.willlayout = QVBoxLayout(viewport)
|
||||||
self.detailsWidget = WillWidget(parent=self)
|
self.detailsWidget = WillWidget(parent=self, will=self.will)
|
||||||
self.willlayout.addWidget(self.detailsWidget)
|
self.willlayout.addWidget(self.detailsWidget)
|
||||||
|
|
||||||
self.scrollbox.setWidget(viewport)
|
self.scrollbox.setWidget(viewport)
|
||||||
viewport.setLayout(self.willlayout)
|
viewport.setLayout(self.willlayout)
|
||||||
|
|
||||||
def ask_password_and_sign_transactions(self):
|
def ask_password_and_sign_transactions(self):
|
||||||
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
|
self.bal_window.ask_password_and_sign_transactions(
|
||||||
|
callback=self.update, will=self.will if self._external_will else None
|
||||||
|
)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def broadcast_transactions(self):
|
def broadcast_transactions(self):
|
||||||
self.bal_window.broadcast_transactions()
|
self.bal_window.broadcast_transactions(
|
||||||
|
will=self.will if self._external_will else None
|
||||||
|
)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def export_will(self):
|
def export_will(self):
|
||||||
self.bal_window.export_will()
|
self.bal_window.export_will(will=self.will if self._external_will else None)
|
||||||
|
|
||||||
|
def invalidate_will(self):
|
||||||
|
self.bal_window.invalidate_will(
|
||||||
|
will=self.will if self._external_will else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def merge_will(self):
|
||||||
|
"""Merge the imported will into the live will and switch to it.
|
||||||
|
|
||||||
|
The merge is performed by :meth:`BalWindow.merge_will` (the same
|
||||||
|
common method used by the tools-menu "Merge" action). Afterwards the
|
||||||
|
dialog stops showing the read-only imported will and operates on the
|
||||||
|
live wallet willitems directly, so any further Sign/Broadcast/Export/
|
||||||
|
Invalidate action targets the saved will items.
|
||||||
|
"""
|
||||||
|
self.bal_window.merge_will(self.will)
|
||||||
|
self._external_will = False
|
||||||
|
self.will = self.bal_window.willitems
|
||||||
|
self.threshold = self.bal_window.will_settings["real_threshold"]
|
||||||
|
if self.merge_button:
|
||||||
|
self.merge_button.hide()
|
||||||
|
self.update()
|
||||||
|
|
||||||
def toggle_replaced(self):
|
def toggle_replaced(self):
|
||||||
self.bal_window.bal_plugin.hide_replaced()
|
self.bal_window.bal_plugin.hide_replaced()
|
||||||
@@ -2060,6 +2125,7 @@ class WillDetailDialog(BalDialog):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
|
if not self._external_will:
|
||||||
self.will = self.bal_window.willitems
|
self.will = self.bal_window.willitems
|
||||||
pos = self.vlayout.indexOf(self.scrollbox)
|
pos = self.vlayout.indexOf(self.scrollbox)
|
||||||
self.vlayout.removeWidget(self.scrollbox)
|
self.vlayout.removeWidget(self.scrollbox)
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ construction) for all business actions, so the heavy logic stays in ``window``
|
|||||||
and ``dialogs``.
|
and ``dialogs``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||||
|
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||||
|
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
|
|
||||||
from PyQt6.QtWidgets import QMessageBox
|
|
||||||
from PyQt6.QtWidgets import QStyledItemDelegate, QLineEdit as _QLineEdit
|
|
||||||
from .dialogs import BalBuildWillDialog, BalDialog
|
from .dialogs import BalBuildWillDialog, BalDialog
|
||||||
|
from .widgets import BalCheckBox, WillSettingsWidget
|
||||||
|
|
||||||
|
|
||||||
class HeirListWidget(MyTreeView, MessageBoxMixin):
|
class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||||
@@ -194,7 +195,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
set_current = QPersistentModelIndex(idx)
|
set_current = QPersistentModelIndex(idx)
|
||||||
try:
|
try:
|
||||||
self.will_settings_widget.on_locktime_change()
|
self.will_settings_widget.on_locktime_change()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.set_current_idx(set_current)
|
self.set_current_idx(set_current)
|
||||||
# FIXME refresh loses sort order; so set "default" here:
|
# FIXME refresh loses sort order; so set "default" here:
|
||||||
@@ -214,15 +215,15 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
||||||
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
||||||
|
|
||||||
newHeirButton = QPushButton(_("New Heir"))
|
new_heir_button = QPushButton(_("New Heir"))
|
||||||
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog)
|
new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
|
||||||
|
|
||||||
widget = QWidget(self)
|
widget = QWidget(self)
|
||||||
layout = QHBoxLayout(widget)
|
layout = QHBoxLayout(widget)
|
||||||
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
||||||
|
|
||||||
layout.addWidget(self.will_settings_widget)
|
layout.addWidget(self.will_settings_widget)
|
||||||
layout.addWidget(newHeirButton)
|
layout.addWidget(new_heir_button)
|
||||||
|
|
||||||
toolbar.insertWidget(2, widget)
|
toolbar.insertWidget(2, widget)
|
||||||
|
|
||||||
@@ -281,7 +282,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
self.setModel(QStandardItemModel(self))
|
self.setModel(QStandardItemModel(self))
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.setSortingEnabled(True)
|
self.setSortingEnabled(True)
|
||||||
@@ -320,13 +321,6 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
_("check ").format(column_title),
|
_("check ").format(column_title),
|
||||||
lambda: self.check_transactions(selected_keys),
|
lambda: self.check_transactions(selected_keys),
|
||||||
)
|
)
|
||||||
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
|
||||||
try:
|
|
||||||
self.importaction = self.menu.addAction(
|
|
||||||
_("Import"), self.import_will
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
@@ -395,8 +389,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
if bal_tx.we:
|
if bal_tx.we:
|
||||||
we = bal_tx.we["url"]
|
we = bal_tx.we["url"]
|
||||||
labels[self.Columns.WILLEXECUTOR] = we
|
labels[self.Columns.WILLEXECUTOR] = we
|
||||||
status = bal_tx.status
|
status = bal_tx.status + signature_suffix(bal_tx)
|
||||||
if len(bal_tx.status) > 53:
|
if len(status) > 53:
|
||||||
status = "...{}".format(status[-50:])
|
status = "...{}".format(status[-50:])
|
||||||
labels[self.Columns.STATUS] = status
|
labels[self.Columns.STATUS] = status
|
||||||
# Dedicated, always-readable label describing whether the inheritance
|
# Dedicated, always-readable label describing whether the inheritance
|
||||||
@@ -473,8 +467,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||||
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
||||||
menu.addAction(_("Export"), self.export_will)
|
menu.addAction(_("Export"), self.export_will)
|
||||||
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
menu.addAction(_("Import"), self.import_will_into_details)
|
||||||
self.importaction = menu.addAction(_("Import"), self.import_will)
|
menu.addAction(_("Merge"), self.merge_will)
|
||||||
menu.addAction(_("Broadcast"), self.broadcast)
|
menu.addAction(_("Broadcast"), self.broadcast)
|
||||||
menu.addAction(_("Check"), self.check)
|
menu.addAction(_("Check"), self.check)
|
||||||
menu.addAction(_("Invalidate"), self.invalidate_will)
|
menu.addAction(_("Invalidate"), self.invalidate_will)
|
||||||
@@ -546,8 +540,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
self.bal_window.export_will()
|
self.bal_window.export_will()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def import_will(self):
|
def import_will_into_details(self):
|
||||||
self.bal_window.import_will()
|
self.bal_window.import_will_into_details()
|
||||||
|
|
||||||
|
def merge_will(self):
|
||||||
|
self.bal_window.merge_will_ui()
|
||||||
|
|
||||||
def ask_password_and_sign_transactions(self):
|
def ask_password_and_sign_transactions(self):
|
||||||
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
|
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ and cached in ``self.bal_windows``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from electrum.gui.qt.main_window import StatusBarButton
|
from electrum.gui.qt.main_window import StatusBarButton
|
||||||
|
from PyQt6.QtWidgets import QLayout
|
||||||
|
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import ( # underscore names are not re-exported by "import *"
|
||||||
from .common import read_QIcon_from_bytes
|
_,
|
||||||
|
_logger,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
)
|
||||||
|
from .dialogs import BalDialog
|
||||||
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
|
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
|
||||||
from .window import BalWindow
|
from .window import BalWindow
|
||||||
from .dialogs import BalDialog
|
|
||||||
from PyQt6.QtWidgets import QLayout
|
|
||||||
|
|
||||||
|
|
||||||
def _window_key(window):
|
def _window_key(window):
|
||||||
@@ -88,9 +91,11 @@ class Plugin(BalPlugin):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
from electrum.gui.qt.plugins_dialog import PluginsDialog
|
from electrum.gui.qt.plugins_dialog import (
|
||||||
|
PluginsDialog as plugins_dialog, # noqa: N813
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
PluginsDialog = None
|
plugins_dialog = None
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is None:
|
if app is None:
|
||||||
return []
|
return []
|
||||||
@@ -106,7 +111,7 @@ class Plugin(BalPlugin):
|
|||||||
for w in app.topLevelWidgets():
|
for w in app.topLevelWidgets():
|
||||||
try:
|
try:
|
||||||
is_match = False
|
is_match = False
|
||||||
if PluginsDialog is not None and isinstance(w, PluginsDialog):
|
if plugins_dialog is not None and isinstance(w, plugins_dialog):
|
||||||
is_match = True
|
is_match = True
|
||||||
elif type(w).__name__ == "PluginsDialog":
|
elif type(w).__name__ == "PluginsDialog":
|
||||||
is_match = True
|
is_match = True
|
||||||
@@ -142,17 +147,17 @@ class Plugin(BalPlugin):
|
|||||||
each is guarded independently.
|
each is guarded independently.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt6.QtWidgets import QDialog
|
from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
|
||||||
except Exception:
|
except Exception:
|
||||||
QDialog = None
|
qdialog = None
|
||||||
# 1) reject() / done(): the reliable way to end an exec() modal loop.
|
# 1) reject() / done(): the reliable way to end an exec() modal loop.
|
||||||
if QDialog is not None and isinstance(d, QDialog):
|
if qdialog is not None and isinstance(d, qdialog):
|
||||||
try:
|
try:
|
||||||
d.reject()
|
d.reject()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("reject() failed: {}".format(e))
|
_logger.debug("reject() failed: {}".format(e))
|
||||||
try:
|
try:
|
||||||
d.done(QDialog.DialogCode.Rejected)
|
d.done(qdialog.DialogCode.Rejected)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("done() failed: {}".format(e))
|
_logger.debug("done() failed: {}".format(e))
|
||||||
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
|
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
|
||||||
@@ -172,9 +177,9 @@ class Plugin(BalPlugin):
|
|||||||
it and closes it themselves (it must not linger in the background).
|
it and closes it themselves (it must not linger in the background).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt6.QtCore import QTimer
|
from PyQt6.QtCore import QTimer as qtimer # noqa: N813
|
||||||
except Exception:
|
except Exception:
|
||||||
QTimer = None
|
qtimer = None
|
||||||
# Schedule of retry delays (ms) measured from each call.
|
# Schedule of retry delays (ms) measured from each call.
|
||||||
retry_delays = [400, 800, 1500]
|
retry_delays = [400, 800, 1500]
|
||||||
dialogs = Plugin._find_plugins_manager_dialogs()
|
dialogs = Plugin._find_plugins_manager_dialogs()
|
||||||
@@ -190,8 +195,8 @@ class Plugin(BalPlugin):
|
|||||||
if not still_open:
|
if not still_open:
|
||||||
_logger.info("plugins dialog closed successfully")
|
_logger.info("plugins dialog closed successfully")
|
||||||
return
|
return
|
||||||
if attempt < len(retry_delays) and QTimer is not None:
|
if attempt < len(retry_delays) and qtimer is not None:
|
||||||
QTimer.singleShot(
|
qtimer.singleShot(
|
||||||
retry_delays[attempt],
|
retry_delays[attempt],
|
||||||
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
|
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
|
||||||
)
|
)
|
||||||
@@ -505,8 +510,10 @@ class Plugin(BalPlugin):
|
|||||||
lbl_event_description, edit_event_description, help_event_description,
|
lbl_event_description, edit_event_description, help_event_description,
|
||||||
lbl_calendar_app, edit_calendar_app, help_calendar_app,
|
lbl_calendar_app, edit_calendar_app, help_calendar_app,
|
||||||
lbl_auto_sign, heir_auto_sign, help_auto_sign,
|
lbl_auto_sign, heir_auto_sign, help_auto_sign,
|
||||||
|
lbl_save_history, heir_save_history, help_save_history,
|
||||||
|
lbl_history_label, edit_history_label, help_history_label,
|
||||||
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
|
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
|
||||||
reset_btn_auto_sign):
|
reset_btn_11, reset_btn_12, reset_btn_auto_sign):
|
||||||
w.setVisible(not basic)
|
w.setVisible(not basic)
|
||||||
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
|
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
|
||||||
# a real USER TYPE change (not inside update_all/CHECK), so pressing
|
# a real USER TYPE change (not inside update_all/CHECK), so pressing
|
||||||
@@ -532,6 +539,20 @@ class Plugin(BalPlugin):
|
|||||||
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
|
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
|
||||||
edit_calendar_app.setMinimumWidth(360)
|
edit_calendar_app.setMinimumWidth(360)
|
||||||
|
|
||||||
|
# "Save inheritance transactions in wallet history" checkbox + label
|
||||||
|
# field (History persistence). When the checkbox is ON, the valid will
|
||||||
|
# transactions are saved into the wallet's LOCAL history (the History
|
||||||
|
# tab) after each check, each tagged with the label below. The label
|
||||||
|
# field is disabled while the checkbox is off, so the user cannot set a
|
||||||
|
# label for a feature that is not active.
|
||||||
|
def on_save_history_change():
|
||||||
|
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
|
||||||
|
|
||||||
|
heir_save_history = BalCheckBox(self.SAVE_HISTORY, on_click=on_save_history_change)
|
||||||
|
edit_history_label = BalLineEdit(self.HISTORY_LABEL)
|
||||||
|
edit_history_label.setMinimumWidth(360)
|
||||||
|
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
|
||||||
|
|
||||||
def _make_reset_btn(cfg, widget, kind):
|
def _make_reset_btn(cfg, widget, kind):
|
||||||
"""Return a small ``↺`` button that resets a single setting."""
|
"""Return a small ``↺`` button that resets a single setting."""
|
||||||
btn = QPushButton("\u21ba")
|
btn = QPushButton("\u21ba")
|
||||||
@@ -742,6 +763,37 @@ class Plugin(BalPlugin):
|
|||||||
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), 11, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_10), 11, 3)
|
||||||
|
|
||||||
|
# Save-in-history toggle and history label: advanced-only rows. The
|
||||||
|
# label field is disabled while the checkbox is off (see
|
||||||
|
# on_save_history_change above).
|
||||||
|
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
|
||||||
|
help_save_history = HelpButton(
|
||||||
|
"After each check, save the valid will transactions into the "
|
||||||
|
"wallet's local history (the History tab), each with a label.\n"
|
||||||
|
"The label may contain the variable:\n"
|
||||||
|
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||||
|
"Only used in ADVANCED mode."
|
||||||
|
)
|
||||||
|
grid.addWidget(_hide_if_basic(lbl_save_history), 12, 0)
|
||||||
|
grid.addWidget(_hide_if_basic(heir_save_history), 12, 1)
|
||||||
|
grid.addWidget(_hide_if_basic(help_save_history), 12, 2)
|
||||||
|
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
|
||||||
|
grid.addWidget(_hide_if_basic(reset_btn_11), 12, 3)
|
||||||
|
|
||||||
|
lbl_history_label = QLabel(_("History label"))
|
||||||
|
help_history_label = HelpButton(
|
||||||
|
"Label applied to the will transactions saved into the wallet's "
|
||||||
|
"local history.\n"
|
||||||
|
"Variables:\n"
|
||||||
|
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||||
|
"Only used in ADVANCED mode."
|
||||||
|
)
|
||||||
|
grid.addWidget(_hide_if_basic(lbl_history_label), 13, 0)
|
||||||
|
grid.addWidget(_hide_if_basic(edit_history_label), 13, 1)
|
||||||
|
grid.addWidget(_hide_if_basic(help_history_label), 13, 2)
|
||||||
|
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
|
||||||
|
grid.addWidget(_hide_if_basic(reset_btn_12), 13, 3)
|
||||||
|
|
||||||
# NOTE: the ADVANCED-only widgets above have ALREADY been given their
|
# 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
|
||||||
# added to the grid. The old code did the opposite - it added them
|
# added to the grid. The old code did the opposite - it added them
|
||||||
@@ -749,12 +801,12 @@ class Plugin(BalPlugin):
|
|||||||
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
||||||
# setVisible() loop here.
|
# setVisible() loop here.
|
||||||
|
|
||||||
grid.addWidget(heir_repush, 12, 0)
|
grid.addWidget(heir_repush, 14, 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"
|
||||||
),
|
),
|
||||||
12,
|
14,
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -788,6 +840,8 @@ class Plugin(BalPlugin):
|
|||||||
(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"),
|
||||||
(self.CALENDAR_APP, edit_calendar_app, "line"),
|
(self.CALENDAR_APP, edit_calendar_app, "line"),
|
||||||
|
(self.SAVE_HISTORY, heir_save_history, "check"),
|
||||||
|
(self.HISTORY_LABEL, edit_history_label, "line"),
|
||||||
]
|
]
|
||||||
for cfg, widget, kind in resets:
|
for cfg, widget, kind in resets:
|
||||||
# Persist the default value back into the Electrum config.
|
# Persist the default value back into the Electrum config.
|
||||||
@@ -808,6 +862,10 @@ class Plugin(BalPlugin):
|
|||||||
widget.setCurrentIndex(
|
widget.setCurrentIndex(
|
||||||
1 if str(cfg.default).lower() == "advanced" else 0
|
1 if str(cfg.default).lower() == "advanced" else 0
|
||||||
)
|
)
|
||||||
|
# Re-sync the history-label field's enabled state after a reset: the
|
||||||
|
# reset restores SAVE_HISTORY to its default, so the field must
|
||||||
|
# follow the (default) checkbox state again.
|
||||||
|
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
|
||||||
# Refresh the open BAL windows so any dependent view (e.g. the
|
# Refresh the open BAL windows so any dependent view (e.g. the
|
||||||
# editable-dates state is not in this list, but hide filters are)
|
# editable-dates state is not in this list, but hide filters are)
|
||||||
# reflects the reset values.
|
# reflects the reset values.
|
||||||
|
|||||||
@@ -54,12 +54,31 @@ def status_color(will_item) -> str:
|
|||||||
return "#e83845" # red - failed to push to will-executor
|
return "#e83845" # red - failed to push to will-executor
|
||||||
elif will_item.get_status("PUSHED"):
|
elif will_item.get_status("PUSHED"):
|
||||||
return "#73f3c8" # teal - pushed to will-executor
|
return "#73f3c8" # teal - pushed to will-executor
|
||||||
|
elif will_item.get_status("PARTIALLY_SIGNED"):
|
||||||
|
return "#ffb347" # amber - some signatures present, more needed
|
||||||
elif will_item.get_status("COMPLETE"):
|
elif will_item.get_status("COMPLETE"):
|
||||||
return "#2bc8ed" # blue - signed
|
return "#2bc8ed" # blue - signed
|
||||||
else:
|
else:
|
||||||
return _DEFAULT_COLOR
|
return _DEFAULT_COLOR
|
||||||
|
|
||||||
|
|
||||||
|
def signature_suffix(will_item) -> str:
|
||||||
|
"""Return the ``" (added/required)"`` suffix for a non-signed will item.
|
||||||
|
|
||||||
|
Used by the transaction list and the detail view to show how many of the
|
||||||
|
required signatures have already been added, e.g. ``"(1/2)"`` for a 2-of-3
|
||||||
|
transaction carrying one signature. Returns ``""`` for signed transactions
|
||||||
|
or when the required count is unknown (no descriptor available yet).
|
||||||
|
"""
|
||||||
|
if will_item.get_status("COMPLETE"):
|
||||||
|
return ""
|
||||||
|
required = int(getattr(will_item, "sigs_required", 0) or 0)
|
||||||
|
added = int(getattr(will_item, "sigs_have", 0) or 0)
|
||||||
|
if not required:
|
||||||
|
return ""
|
||||||
|
return " ({}/{})".format(added, required)
|
||||||
|
|
||||||
|
|
||||||
def server_status_text(will_item) -> str:
|
def server_status_text(will_item) -> str:
|
||||||
"""Return a short, human-readable label describing the state of a will
|
"""Return a short, human-readable label describing the state of a will
|
||||||
item on the will-executor servers (the online inheritance backup).
|
item on the will-executor servers (the online inheritance backup).
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ Contents:
|
|||||||
* WillWidget - single will-tx box
|
* WillWidget - single will-tx box
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .calendar import BalCalendar, BalCalendarButton
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .calendar import BalCalendar, BalCalendarButton
|
|
||||||
|
|
||||||
|
|
||||||
def compute_reminder_offsets(days, count):
|
def compute_reminder_offsets(days, count):
|
||||||
@@ -690,7 +690,7 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
x = int(x)
|
x = int(x)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
x = QDateTime.currentDateTime().timestamp()
|
x = QDateTime.currentDateTime().timestamp()
|
||||||
finally:
|
finally:
|
||||||
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
||||||
@@ -1395,15 +1395,15 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
if self.base_unit:
|
if self.base_unit:
|
||||||
panel = QStyleOptionFrame()
|
panel = QStyleOptionFrame()
|
||||||
self.initStyleOption(panel)
|
self.initStyleOption(panel)
|
||||||
textRect = self.style().subElementRect(
|
text_rect = self.style().subElementRect(
|
||||||
QStyle.SubElement.SE_LineEditContents, panel, self
|
QStyle.SubElement.SE_LineEditContents, panel, self
|
||||||
)
|
)
|
||||||
textRect.adjust(2, 0, -10, 0)
|
text_rect.adjust(2, 0, -10, 0)
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setPen(ColorScheme.GRAY.as_color())
|
painter.setPen(ColorScheme.GRAY.as_color())
|
||||||
if len(self.text()) == 0:
|
if len(self.text()) == 0:
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
textRect,
|
text_rect,
|
||||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
||||||
self.base_unit() + " or perc value",
|
self.base_unit() + " or perc value",
|
||||||
)
|
)
|
||||||
@@ -1482,11 +1482,11 @@ class BalSpinBox(QSpinBox):
|
|||||||
|
|
||||||
|
|
||||||
class WillWidget(QWidget):
|
class WillWidget(QWidget):
|
||||||
def __init__(self, father=None, parent=None):
|
def __init__(self, father=None, parent=None, will=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
vlayout = QVBoxLayout()
|
vlayout = QVBoxLayout()
|
||||||
self.setLayout(vlayout)
|
self.setLayout(vlayout)
|
||||||
self.will = parent.bal_window.willitems
|
self.will = will if will is not None else parent.bal_window.willitems
|
||||||
self._bal_parent = parent
|
self._bal_parent = parent
|
||||||
for w in self.will:
|
for w in self.will:
|
||||||
if (
|
if (
|
||||||
@@ -1513,7 +1513,10 @@ class WillWidget(QWidget):
|
|||||||
willpushbutton = QPushButton(w)
|
willpushbutton = QPushButton(w)
|
||||||
|
|
||||||
willpushbutton.clicked.connect(
|
willpushbutton.clicked.connect(
|
||||||
partial(self._bal_parent.bal_window.show_transaction, txid=w)
|
partial(
|
||||||
|
self._bal_parent.bal_window.show_transaction,
|
||||||
|
tx=self.will[w].tx,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
detaillayout.addWidget(willpushbutton)
|
detaillayout.addWidget(willpushbutton)
|
||||||
locktime = str(BalTimestamp(self.will[w].tx.locktime))
|
locktime = str(BalTimestamp(self.will[w].tx.locktime))
|
||||||
@@ -1535,7 +1538,9 @@ class WillWidget(QWidget):
|
|||||||
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
||||||
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
||||||
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
||||||
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
|
detaillayout.addWidget(
|
||||||
|
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
|
||||||
|
)
|
||||||
detaillayout.addWidget(QLabel(""))
|
detaillayout.addWidget(QLabel(""))
|
||||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
||||||
for heir in self.will[w].heirs:
|
for heir in self.will[w].heirs:
|
||||||
@@ -1570,6 +1575,6 @@ class WillWidget(QWidget):
|
|||||||
detailw.setPalette(pal)
|
detailw.setPalette(pal)
|
||||||
|
|
||||||
hlayout.addWidget(detailw)
|
hlayout.addWidget(detailw)
|
||||||
hlayout.addWidget(WillWidget(w, parent=parent))
|
hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ import threading
|
|||||||
|
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget
|
from .dialogs import (
|
||||||
from .dialogs import (BalBlockingWaitingDialog, BalBuildWillDialog, BalDialog,
|
BalBuildWillDialog,
|
||||||
BalWaitingDialog, BalWizardDialog, WillDetailDialog,
|
BalDialog,
|
||||||
WillExecutorDialog)
|
BalWaitingDialog,
|
||||||
from .lists import HeirListWidget, PreviewList, WillExecutorWidget
|
BalWizardDialog,
|
||||||
|
WillDetailDialog,
|
||||||
|
WillExecutorDialog,
|
||||||
|
)
|
||||||
|
from .lists import HeirListWidget, PreviewList
|
||||||
|
from .widgets import LockTimeWidget, PercAmountEdit
|
||||||
|
|
||||||
|
|
||||||
class BalWindow:
|
class BalWindow:
|
||||||
@@ -350,6 +355,12 @@ class BalWindow:
|
|||||||
will = self.build_inheritance_transaction(
|
will = self.build_inheritance_transaction(
|
||||||
ignore_duplicate=ignore_duplicate, keep_original=keep_original
|
ignore_duplicate=ignore_duplicate, keep_original=keep_original
|
||||||
)
|
)
|
||||||
|
# Persist the freshly prepared transactions into the wallet's local
|
||||||
|
# history (when SAVE_HISTORY is enabled). This runs on every successful
|
||||||
|
# prepare -- including the "Prepare" menu action -- so the New txs show
|
||||||
|
# up in History immediately. Abort paths return None and are skipped.
|
||||||
|
if will:
|
||||||
|
self._save_will_to_history()
|
||||||
return will
|
return will
|
||||||
|
|
||||||
def delete_not_valid(self, txid, s_utxo):
|
def delete_not_valid(self, txid, s_utxo):
|
||||||
@@ -388,12 +399,20 @@ class BalWindow:
|
|||||||
# date_to_check already carries the correct reference timestamp for
|
# date_to_check already carries the correct reference timestamp for
|
||||||
# the current mode (the Check Alive in ADVANCED, or "now" in BASIC -
|
# the current mode (the Check Alive in ADVANCED, or "now" in BASIC -
|
||||||
# see init_class_variables). So build the will directly against it;
|
# see init_class_variables). So build the will directly against it;
|
||||||
# no per-mode branch is needed here anymore.
|
# no per-mode branch is needed here anymore. The available-UTXO view
|
||||||
|
# restores coins that a newer, wallet-local will tx (stored in the
|
||||||
|
# history with a later locktime) nominally spent.
|
||||||
txs = self.heirs.get_transactions(
|
txs = self.heirs.get_transactions(
|
||||||
self.bal_plugin,
|
self.bal_plugin,
|
||||||
self.window.wallet,
|
self.window.wallet,
|
||||||
self.will_settings["baltx_fees"],
|
self.will_settings["baltx_fees"],
|
||||||
None,
|
Util.get_available_utxos(
|
||||||
|
self.window.wallet,
|
||||||
|
self.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
Will.get_min_locktime(
|
||||||
|
self.willitems, default_value=self.date_to_check
|
||||||
|
),
|
||||||
|
),
|
||||||
self.date_to_check,
|
self.date_to_check,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -429,17 +448,75 @@ class BalWindow:
|
|||||||
return self.willitems
|
return self.willitems
|
||||||
|
|
||||||
def check_will(self):
|
def check_will(self):
|
||||||
return Will.is_will_valid(
|
result = Will.is_will_valid(
|
||||||
self.willitems,
|
self.willitems,
|
||||||
self.date_to_check,
|
self.date_to_check,
|
||||||
self.will_settings["baltx_fees"],
|
self.will_settings["baltx_fees"],
|
||||||
self.window.wallet.get_utxos(),
|
Util.get_available_utxos(
|
||||||
|
self.window.wallet,
|
||||||
|
self.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
Will.get_min_locktime(
|
||||||
|
self.willitems, default_value=self.date_to_check
|
||||||
|
),
|
||||||
|
),
|
||||||
heirs=self.heirs,
|
heirs=self.heirs,
|
||||||
willexecutors=self.willexecutors,
|
willexecutors=self.willexecutors,
|
||||||
self_willexecutor=self.no_willexecutor,
|
self_willexecutor=self.no_willexecutor,
|
||||||
wallet=self.wallet,
|
wallet=self.wallet,
|
||||||
callback_not_valid_tx=self.delete_not_valid,
|
callback_not_valid_tx=self.delete_not_valid,
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _save_will_to_history(self):
|
||||||
|
"""Persist the current will state into the wallet's LOCAL history.
|
||||||
|
|
||||||
|
Runs after the will has been prepared/built/signed/checked (the
|
||||||
|
"Prepare" action, the check dialog's phase 2 and the manual Sign
|
||||||
|
action). When the SAVE_HISTORY setting is enabled,
|
||||||
|
``Will.save_valid_transactions_to_history`` stores the still "New" (not
|
||||||
|
fully-signed) transactions under the configured label and removes
|
||||||
|
entries for fully-signed ("Complete") and stale ones. The wallet tabs
|
||||||
|
are then re-rendered through ``_refresh_after_history_save``.
|
||||||
|
|
||||||
|
This must never raise: history persistence is a convenience on top of
|
||||||
|
the will flows, so any failure is logged and ignored.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not bool(self.bal_plugin.SAVE_HISTORY.get()):
|
||||||
|
return
|
||||||
|
Will.save_valid_transactions_to_history(
|
||||||
|
self.willitems,
|
||||||
|
self.wallet,
|
||||||
|
self.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"save_will_to_history failed: {e}")
|
||||||
|
self._schedule_history_refresh()
|
||||||
|
|
||||||
|
def _schedule_history_refresh(self):
|
||||||
|
"""Re-render the wallet tabs after the local history has changed.
|
||||||
|
|
||||||
|
The actual refresh must run on the GUI thread (``HistoryModel.refresh``
|
||||||
|
asserts that), so the call is marshalled through ``QTimer.singleShot``.
|
||||||
|
Used after saving/removing will transactions in the local history and
|
||||||
|
after a will rebuild, regardless of the calling thread.
|
||||||
|
"""
|
||||||
|
QTimer.singleShot(0, self._refresh_after_history_save)
|
||||||
|
|
||||||
|
def _refresh_after_history_save(self):
|
||||||
|
"""Re-render the wallet tabs after saving txs to the local history.
|
||||||
|
|
||||||
|
``update_tabs`` refreshes history plus the receive/send/address/coins
|
||||||
|
lists; ``update_status`` refreshes the status-bar balance, which
|
||||||
|
``update_tabs`` does not touch. When ``update_tabs`` is not available we
|
||||||
|
fall back to refreshing just the History tab.
|
||||||
|
"""
|
||||||
|
if hasattr(self.window, "update_tabs"):
|
||||||
|
self.window.update_tabs()
|
||||||
|
elif hasattr(self.window, "history_list"):
|
||||||
|
self.window.history_list.update()
|
||||||
|
if hasattr(self.window, "update_status"):
|
||||||
|
self.window.update_status()
|
||||||
|
|
||||||
def show_message(self, text):
|
def show_message(self, text):
|
||||||
self.window.show_message(text)
|
self.window.show_message(text)
|
||||||
@@ -589,7 +666,13 @@ class BalWindow:
|
|||||||
Will.check_amounts(
|
Will.check_amounts(
|
||||||
self.heirs,
|
self.heirs,
|
||||||
self.willexecutors,
|
self.willexecutors,
|
||||||
self.window.wallet.get_utxos(),
|
Util.get_available_utxos(
|
||||||
|
self.window.wallet,
|
||||||
|
self.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
Will.get_min_locktime(
|
||||||
|
self.willitems, default_value=self.date_to_check
|
||||||
|
),
|
||||||
|
),
|
||||||
self.date_to_check,
|
self.date_to_check,
|
||||||
self.window.wallet.dust_threshold(),
|
self.window.wallet.dust_threshold(),
|
||||||
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||||
@@ -735,8 +818,7 @@ class BalWindow:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.window.history_list.update()
|
self._schedule_history_refresh()
|
||||||
self.window.utxo_list.update()
|
|
||||||
|
|
||||||
# Guide the user: the inheritance was just (re)built and is now
|
# Guide the user: the inheritance was just (re)built and is now
|
||||||
# in the "New" state, so it must be SIGNED and then BROADCAST
|
# in the "New" state, so it must be SIGNED and then BROADCAST
|
||||||
@@ -806,7 +888,7 @@ class BalWindow:
|
|||||||
raise Exception(_("no tx"))
|
raise Exception(_("no tx"))
|
||||||
return self.show_transaction_real(tx, parent=parent)
|
return self.show_transaction_real(tx, parent=parent)
|
||||||
|
|
||||||
def invalidate_will(self):
|
def invalidate_will(self, will=None):
|
||||||
def on_success(result):
|
def on_success(result):
|
||||||
if result:
|
if result:
|
||||||
self.show_message(
|
self.show_message(
|
||||||
@@ -823,16 +905,27 @@ class BalWindow:
|
|||||||
def on_failure(exec_info):
|
def on_failure(exec_info):
|
||||||
log_error(exec_info, self.bal_window)
|
log_error(exec_info, self.bal_window)
|
||||||
|
|
||||||
|
willitems = will if will is not None else self.willitems
|
||||||
fee_per_byte = self.will_settings.get("baltx_fees", 1)
|
fee_per_byte = self.will_settings.get("baltx_fees", 1)
|
||||||
task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte)
|
task = partial(
|
||||||
|
Will.invalidate_will,
|
||||||
|
willitems,
|
||||||
|
self.wallet,
|
||||||
|
fee_per_byte,
|
||||||
|
history_label=self.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
will_locktime=Will.get_min_locktime(
|
||||||
|
willitems, default_value=self.date_to_check
|
||||||
|
),
|
||||||
|
)
|
||||||
msg = _("Calculating Transactions")
|
msg = _("Calculating Transactions")
|
||||||
self.waiting_dialog = BalWaitingDialog(
|
self.waiting_dialog = BalWaitingDialog(
|
||||||
self, msg, task, on_success, on_failure, exe=False
|
self, msg, task, on_success, on_failure, exe=False
|
||||||
)
|
)
|
||||||
self.waiting_dialog.exe()
|
self.waiting_dialog.exe()
|
||||||
|
|
||||||
def sign_transactions(self, password):
|
def sign_transactions(self, password, will=None):
|
||||||
try:
|
try:
|
||||||
|
willitems = will if will is not None else self.willitems
|
||||||
txs = {}
|
txs = {}
|
||||||
signed = None
|
signed = None
|
||||||
tosign = None
|
tosign = None
|
||||||
@@ -843,8 +936,8 @@ class BalWindow:
|
|||||||
msg = _(f"signed: {signed}\n")
|
msg = _(f"signed: {signed}\n")
|
||||||
return msg + _(f"signing: {tosign}")
|
return msg + _(f"signing: {tosign}")
|
||||||
|
|
||||||
for txid in Will.only_valid(self.willitems):
|
for txid in Will.only_valid(willitems):
|
||||||
wi = self.willitems[txid]
|
wi = willitems[txid]
|
||||||
tx = copy.deepcopy(wi.tx)
|
tx = copy.deepcopy(wi.tx)
|
||||||
if wi.get_status("COMPLETE"):
|
if wi.get_status("COMPLETE"):
|
||||||
txs[txid] = tx
|
txs[txid] = tx
|
||||||
@@ -856,8 +949,8 @@ class BalWindow:
|
|||||||
pass
|
pass
|
||||||
for txin in tx.inputs():
|
for txin in tx.inputs():
|
||||||
prevout = txin.prevout.to_json()
|
prevout = txin.prevout.to_json()
|
||||||
if prevout[0] in self.willitems:
|
if prevout[0] in willitems:
|
||||||
change = self.willitems[prevout[0]].tx.outputs()[prevout[1]]
|
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
||||||
txin._trusted_value_sats = change.value
|
txin._trusted_value_sats = change.value
|
||||||
try:
|
try:
|
||||||
txin.script_descriptor = change.script_descriptor
|
txin.script_descriptor = change.script_descriptor
|
||||||
@@ -874,6 +967,16 @@ class BalWindow:
|
|||||||
if tx.is_complete():
|
if tx.is_complete():
|
||||||
# is_complete = True
|
# is_complete = True
|
||||||
wi.set_status("COMPLETE", True)
|
wi.set_status("COMPLETE", True)
|
||||||
|
# Refresh the per-item signature counts from the freshly signed
|
||||||
|
# partial tx: at this point the signatures are still present
|
||||||
|
# (before any finalization), so the will list can show the real
|
||||||
|
# "added/required" count (e.g. "1/2" for a multisig).
|
||||||
|
try:
|
||||||
|
have, required = tx.signature_count()
|
||||||
|
wi.sigs_have = int(have)
|
||||||
|
wi.sigs_required = int(required)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f"signature_count after signing failed: {e}")
|
||||||
txs[txid] = tx
|
txs[txid] = tx
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
@@ -940,16 +1043,29 @@ class BalWindow:
|
|||||||
# re-wire them if this same window is reused for another wallet.
|
# re-wire them if this same window is reused for another wallet.
|
||||||
self._menubar_initialized = False
|
self._menubar_initialized = False
|
||||||
|
|
||||||
def ask_password_and_sign_transactions(self, callback=None):
|
def ask_password_and_sign_transactions(self, callback=None, will=None):
|
||||||
|
external = will is not None
|
||||||
|
willitems = will if external else self.willitems
|
||||||
|
|
||||||
def on_success(txs):
|
def on_success(txs):
|
||||||
if txs:
|
if txs:
|
||||||
for txid, tx in txs.items():
|
for txid, tx in txs.items():
|
||||||
self.willitems[txid].tx = copy.deepcopy(tx)
|
willitems[txid].tx = copy.deepcopy(tx)
|
||||||
self.will[txid] = self.willitems[txid].to_dict()
|
if not external:
|
||||||
|
self.will[txid] = willitems[txid].to_dict()
|
||||||
|
try:
|
||||||
|
Will.check_signatures(willitems, self.wallet)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"check_signatures after signing failed: {e}")
|
||||||
|
if not external:
|
||||||
try:
|
try:
|
||||||
self.will_list_widget.update()
|
self.will_list_widget.update()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# After signing, keep the local history in sync (save the still
|
||||||
|
# incomplete "New" txs, remove the now-complete ones).
|
||||||
|
if not external:
|
||||||
|
self._save_will_to_history()
|
||||||
if callback:
|
if callback:
|
||||||
try:
|
try:
|
||||||
callback()
|
callback()
|
||||||
@@ -960,15 +1076,18 @@ class BalWindow:
|
|||||||
log_error(exec_info, self.bal_window)
|
log_error(exec_info, self.bal_window)
|
||||||
|
|
||||||
password = self.get_wallet_password()
|
password = self.get_wallet_password()
|
||||||
task = partial(self.sign_transactions, password)
|
task = partial(self.sign_transactions, password, will=will)
|
||||||
msg = _("Signing transactions...")
|
msg = _("Signing transactions...")
|
||||||
self.waiting_dialog = BalWaitingDialog(
|
self.waiting_dialog = BalWaitingDialog(
|
||||||
self, msg, task, on_success, on_failure, exe=False
|
self, msg, task, on_success, on_failure, exe=False
|
||||||
)
|
)
|
||||||
self.waiting_dialog.exe()
|
self.waiting_dialog.exe()
|
||||||
|
|
||||||
def broadcast_transactions(self, force=False):
|
def broadcast_transactions(self, force=False, will=None):
|
||||||
|
external = will is not None
|
||||||
|
|
||||||
def on_success(sulcess):
|
def on_success(sulcess):
|
||||||
|
if not external:
|
||||||
self.will_list_widget.update()
|
self.will_list_widget.update()
|
||||||
if sulcess:
|
if sulcess:
|
||||||
_logger.info("error, some transaction was not sent")
|
_logger.info("error, some transaction was not sent")
|
||||||
@@ -994,15 +1113,16 @@ class BalWindow:
|
|||||||
# _logger.error("lasti:", tb.tb_lasti)
|
# _logger.error("lasti:", tb.tb_lasti)
|
||||||
# tb = tb.tb_next
|
# tb = tb.tb_next
|
||||||
|
|
||||||
task = partial(self.push_transactions_to_willexecutors, force)
|
task = partial(self.push_transactions_to_willexecutors, force, will=will)
|
||||||
msg = _("Selecting Will-Executors")
|
msg = _("Selecting Will-Executors")
|
||||||
self.waiting_dialog = BalWaitingDialog(
|
self.waiting_dialog = BalWaitingDialog(
|
||||||
self, msg, task, on_success, on_failure, exe=False
|
self, msg, task, on_success, on_failure, exe=False
|
||||||
)
|
)
|
||||||
self.waiting_dialog.exe()
|
self.waiting_dialog.exe()
|
||||||
|
|
||||||
def push_transactions_to_willexecutors(self, force=False):
|
def push_transactions_to_willexecutors(self, force=False, will=None):
|
||||||
willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems, force=force)
|
willitems = will if will is not None else self.willitems
|
||||||
|
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||||
|
|
||||||
def getMsg(willexecutors):
|
def getMsg(willexecutors):
|
||||||
msg = "Broadcasting Transactions to Will-Executors:\n"
|
msg = "Broadcasting Transactions to Will-Executors:\n"
|
||||||
@@ -1031,11 +1151,11 @@ class BalWindow:
|
|||||||
willexecutor["broadcast_status"] = _("checking...")
|
willexecutor["broadcast_status"] = _("checking...")
|
||||||
elif ok:
|
elif ok:
|
||||||
for wid in willexecutor.get("txsids", []):
|
for wid in willexecutor.get("txsids", []):
|
||||||
self.willitems[wid].set_status("PUSHED", True)
|
willitems[wid].set_status("PUSHED", True)
|
||||||
willexecutor["broadcast_status"] = _("Success")
|
willexecutor["broadcast_status"] = _("Success")
|
||||||
else:
|
else:
|
||||||
for wid in willexecutor.get("txsids", []):
|
for wid in willexecutor.get("txsids", []):
|
||||||
self.willitems[wid].set_status("PUSH_FAIL", True)
|
willitems[wid].set_status("PUSH_FAIL", True)
|
||||||
error["flag"] = True
|
error["flag"] = True
|
||||||
willexecutor["broadcast_status"] = _("Failed")
|
willexecutor["broadcast_status"] = _("Failed")
|
||||||
willexecutor.pop("txs", None)
|
willexecutor.pop("txs", None)
|
||||||
@@ -1060,54 +1180,171 @@ class BalWindow:
|
|||||||
return
|
return
|
||||||
self.waiting_dialog.update(
|
self.waiting_dialog.update(
|
||||||
"checking {} - {} : {}".format(
|
"checking {} - {} : {}".format(
|
||||||
self.willitems[wid].we["url"], wid, "Waiting"
|
willitems[wid].we["url"], wid, "Waiting"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
w = self.willitems[wid]
|
w = willitems[wid]
|
||||||
w.set_check_willexecutor(
|
w.set_check_willexecutor(
|
||||||
Willexecutors.check_transaction(wid, w.we["url"])
|
Willexecutors.check_transaction(wid, w.we["url"])
|
||||||
)
|
)
|
||||||
self.waiting_dialog.update(
|
self.waiting_dialog.update(
|
||||||
"checked {} - {} : {}".format(
|
"checked {} - {} : {}".format(
|
||||||
self.willitems[wid].we["url"],
|
willitems[wid].we["url"],
|
||||||
wid,
|
wid,
|
||||||
self.willitems[wid].get_status("CHECKED"),
|
willitems[wid].get_status("CHECKED"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if error["flag"]:
|
if error["flag"]:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def export_json_file(self, path):
|
def export_json_file(self, path, will=None):
|
||||||
|
if will is None:
|
||||||
for wid in self.willitems:
|
for wid in self.willitems:
|
||||||
self.willitems[wid].set_status("EXPORTED", True)
|
self.willitems[wid].set_status("EXPORTED", True)
|
||||||
self.will[wid] = self.willitems[wid].to_dict()
|
self.will[wid] = self.willitems[wid].to_dict()
|
||||||
write_json_file(path, self.will)
|
write_json_file(path, self.will)
|
||||||
|
else:
|
||||||
|
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
|
||||||
|
|
||||||
def export_will(self):
|
def export_will(self, will=None):
|
||||||
try:
|
try:
|
||||||
export_meta_gui(self.window, "will.json", self.export_json_file)
|
export_meta_gui(
|
||||||
|
self.window, "will.json", partial(self.export_json_file, will=will)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.show_error(str(e))
|
self.show_error(str(e))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def import_will(self):
|
def merge_will(self, imported):
|
||||||
def sulcess():
|
"""Merge imported will items into the live will.
|
||||||
|
|
||||||
|
Both the tools-menu "Merge" action and the details-dialog "Merge"
|
||||||
|
button go through this single method.
|
||||||
|
|
||||||
|
For a transaction that already exists in the live will the live
|
||||||
|
WillItem is kept (never replaced): only the operational statuses
|
||||||
|
(signed/pushed/checked/mempool/confirmed) that are True in the
|
||||||
|
imported item are carried over. When the live transaction is not
|
||||||
|
yet signed the imported transaction is merged into it (signatures
|
||||||
|
are combined when both are the same unsigned tx, otherwise the
|
||||||
|
transaction is substituted); an already-signed live transaction is
|
||||||
|
left untouched. Transactions that are new are added wholesale.
|
||||||
|
|
||||||
|
After the merge a local validity check recomputes the
|
||||||
|
valid/invalidated/replaced statuses (no server contact, no expiry
|
||||||
|
raise).
|
||||||
|
"""
|
||||||
|
for wid, wi in imported.items():
|
||||||
|
if wid in self.willitems:
|
||||||
|
live = self.willitems[wid]
|
||||||
|
was_complete = live.get_status("COMPLETE")
|
||||||
|
for status in (
|
||||||
|
"COMPLETE",
|
||||||
|
"PUSHED",
|
||||||
|
"CHECKED",
|
||||||
|
"MEMPOOL",
|
||||||
|
"CONFIRMED",
|
||||||
|
):
|
||||||
|
if wi.get_status(status):
|
||||||
|
live.set_status(status, True)
|
||||||
|
if not was_complete:
|
||||||
|
try:
|
||||||
|
if live.tx.txid() == wi.tx.txid():
|
||||||
|
live.tx.combine_with_other_psbt(wi.tx)
|
||||||
|
else:
|
||||||
|
live.tx = wi.tx
|
||||||
|
except Exception:
|
||||||
|
live.tx = wi.tx
|
||||||
|
if live.tx.is_complete():
|
||||||
|
live.set_status("COMPLETE", True)
|
||||||
|
else:
|
||||||
|
self.willitems[wid] = wi
|
||||||
|
Will.normalize_will(self.willitems, self.wallet)
|
||||||
|
self.save_willitems()
|
||||||
|
# Local validity check: recompute valid/invalidated/replaced statuses.
|
||||||
|
try:
|
||||||
|
Will.add_willtree(self.willitems)
|
||||||
|
bal_plugin = getattr(self, "bal_plugin", None)
|
||||||
|
history_label = (
|
||||||
|
bal_plugin.HISTORY_LABEL.get() if bal_plugin is not None else None
|
||||||
|
)
|
||||||
|
all_utxos = Util.get_available_utxos(
|
||||||
|
self.wallet,
|
||||||
|
history_label,
|
||||||
|
Will.get_min_locktime(
|
||||||
|
self.willitems, default_value=self.date_to_check
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Will.check_invalidated(
|
||||||
|
self.willitems, Will.utxos_strs(all_utxos), self.wallet
|
||||||
|
)
|
||||||
|
Will.search_rai(
|
||||||
|
Will.get_all_inputs(self.willitems, only_valid=True),
|
||||||
|
all_utxos,
|
||||||
|
self.willitems,
|
||||||
|
self.wallet,
|
||||||
|
)
|
||||||
|
Will.check_signatures(self.willitems, self.wallet)
|
||||||
|
except Exception as e:
|
||||||
|
log_error(e, self.bal_window)
|
||||||
|
self.save_willitems()
|
||||||
|
self.update_all()
|
||||||
|
|
||||||
|
def merge_will_from_file(self, path):
|
||||||
|
try:
|
||||||
|
willitems = self._load_will_file(path)
|
||||||
|
except Exception as e:
|
||||||
|
raise FileImportFailed(_("Invalid will file: {}").format(e)) from None
|
||||||
|
Will.normalize_will(willitems, self.wallet)
|
||||||
|
self.merge_will(willitems)
|
||||||
|
|
||||||
|
def merge_will_ui(self):
|
||||||
|
def on_success():
|
||||||
self.will_list_widget.update_will(self.willitems)
|
self.will_list_widget.update_will(self.willitems)
|
||||||
|
|
||||||
import_meta_gui(self.window, _("will"), self.import_json_file, sulcess)
|
import_meta_gui(self.window, _("will"), self.merge_will_from_file, on_success)
|
||||||
|
|
||||||
def import_json_file(self, path):
|
def import_will_into_details(self):
|
||||||
|
"""Import a will file and show it in a WillDetails window.
|
||||||
|
|
||||||
|
Unlike the "Merge" actions (which merge the file into the active
|
||||||
|
will), this is a read-only preview: the parsed will is shown in a
|
||||||
|
:class:`WillDetailDialog` and the live wallet state is never touched.
|
||||||
|
The dialog's Sign/Broadcast/Export/Invalidate buttons operate on the
|
||||||
|
imported will only, and its Merge button merges the imported will
|
||||||
|
into the live one.
|
||||||
|
"""
|
||||||
|
imported = {}
|
||||||
|
|
||||||
|
def on_file(path):
|
||||||
try:
|
try:
|
||||||
|
willitems = self._load_will_file(path)
|
||||||
|
except Exception as e:
|
||||||
|
self.show_error(_("Invalid will file: {}").format(e))
|
||||||
|
return
|
||||||
|
# Attach wallet/input info so the imported txs can be signed and
|
||||||
|
# broadcast (mirrors what merge_will_from_file does).
|
||||||
|
Will.normalize_will(willitems, self.wallet)
|
||||||
|
for wi in willitems.values():
|
||||||
|
wi.set_status("IMPORTED", True)
|
||||||
|
imported.update(willitems)
|
||||||
|
|
||||||
|
def on_success():
|
||||||
|
if not imported:
|
||||||
|
return
|
||||||
|
d = WillDetailDialog(self, will=imported)
|
||||||
|
show_on_top(d)
|
||||||
|
|
||||||
|
import_meta_gui(self.window, _("will"), on_file, on_success)
|
||||||
|
|
||||||
|
def _load_will_file(self, path):
|
||||||
data = read_json_file(path)
|
data = read_json_file(path)
|
||||||
willitems = {}
|
willitems = {}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
data[k]["tx"] = tx_from_any(v["tx"])
|
data[k]["tx"] = tx_from_any(v["tx"])
|
||||||
willitems[k] = WillItem(data[k], _id=k)
|
willitems[k] = WillItem(data[k], _id=k)
|
||||||
self.update_will(willitems)
|
return willitems
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
# raise FileImportFailed(_("Invalid will file"))
|
|
||||||
|
|
||||||
def check_transactions_task(self, will):
|
def check_transactions_task(self, will):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
@@ -1502,7 +1739,13 @@ class BalWindow:
|
|||||||
for _wid, _w in list(self.willitems.items())[:3]:
|
for _wid, _w in list(self.willitems.items())[:3]:
|
||||||
_logger.debug(f"NoneType_debug willitems[{_wid}] type={type(_w).__name__}")
|
_logger.debug(f"NoneType_debug willitems[{_wid}] type={type(_w).__name__}")
|
||||||
Will.add_willtree(self.willitems)
|
Will.add_willtree(self.willitems)
|
||||||
all_utxos = self.wallet.get_utxos()
|
all_utxos = Util.get_available_utxos(
|
||||||
|
self.wallet,
|
||||||
|
self.bal_plugin.HISTORY_LABEL.get(),
|
||||||
|
Will.get_min_locktime(
|
||||||
|
self.willitems, default_value=self.date_to_check
|
||||||
|
),
|
||||||
|
)
|
||||||
utxos_list = Will.utxos_strs(all_utxos)
|
utxos_list = Will.utxos_strs(all_utxos)
|
||||||
Will.check_invalidated(self.willitems, utxos_list, self.wallet)
|
Will.check_invalidated(self.willitems, utxos_list, self.wallet)
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,15 @@ target-version = "py312"
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select =["E", "W", "F", "I", "N", "B"]
|
select =["E", "W", "F", "I", "N", "B"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"bal/gui/qt/*.py" = ["F403", "F405"] # intentional `from .common import *` hub
|
||||||
|
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via `import *`
|
||||||
|
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
|
||||||
|
"bal/gui/qt/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
|
||||||
|
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
|
||||||
|
"bal/gui/qt/window.py" = ["N802"] # getMsg
|
||||||
|
"bal/core/heirs.py" = ["N818", "N802"] # public exception names + buildTransactions API
|
||||||
|
"bal/core/will.py" = ["N818"] # public exception names
|
||||||
|
"bal/core/willexecutors.py" = ["N818"] # public exception names
|
||||||
|
"tests/*.py" = ["N802", "E402"] # deliberate UPPER_CASE helpers + sys.path-before-import
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ def _active_source_without_strings(module) -> str:
|
|||||||
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
|
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
|
||||||
self.spans.append((node.lineno, node.end_lineno))
|
self.spans.append((node.lineno, node.end_lineno))
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
s = _S(); s.visit(tree)
|
s = _S()
|
||||||
|
s.visit(tree)
|
||||||
drop = set()
|
drop = set()
|
||||||
for a, b in s.spans:
|
for a, b in s.spans:
|
||||||
drop.update(range(a, b + 1))
|
drop.update(range(a, b + 1))
|
||||||
@@ -45,12 +46,13 @@ def _active_source_without_strings(module) -> str:
|
|||||||
|
|
||||||
def main(pkg: str) -> int:
|
def main(pkg: str) -> int:
|
||||||
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
||||||
app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
|
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
|
||||||
|
|
||||||
# top_level_of: returns the top-level container of a child widget
|
# top_level_of: returns the top-level container of a child widget
|
||||||
w = QWidget(); child = QWidget(w)
|
w = QWidget()
|
||||||
|
child = QWidget(w)
|
||||||
assert wu.top_level_of(child) is w
|
assert wu.top_level_of(child) is w
|
||||||
assert wu.top_level_of(None) is None
|
assert wu.top_level_of(None) is None
|
||||||
print("[OK] top_level_of")
|
print("[OK] top_level_of")
|
||||||
|
|||||||
455
tests/karen7
455
tests/karen7
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ N = 8 # number of servers
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
|
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
|
||||||
W = we_mod.Willexecutors
|
we_cls = we_mod.Willexecutors
|
||||||
|
|
||||||
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
|
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
|
||||||
def slow_get_info(url, we, **kwargs):
|
def slow_get_info(url, we, **kwargs):
|
||||||
@@ -43,8 +43,8 @@ def main():
|
|||||||
we["status"] = 200
|
we["status"] = 200
|
||||||
return we
|
return we
|
||||||
|
|
||||||
orig_get_info = W.get_info_task
|
orig_get_info = we_cls.get_info_task
|
||||||
W.get_info_task = staticmethod(slow_get_info)
|
we_cls.get_info_task = staticmethod(slow_get_info)
|
||||||
try:
|
try:
|
||||||
wes = {}
|
wes = {}
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -57,7 +57,7 @@ def main():
|
|||||||
seen.append((url, ok))
|
seen.append((url, ok))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
|
|
||||||
# Sequential would take ~ N * SLOW. Parallel must be far less.
|
# Sequential would take ~ N * SLOW. Parallel must be far less.
|
||||||
@@ -81,15 +81,15 @@ def main():
|
|||||||
assert we["status"] == "KO", (url, we)
|
assert we["status"] == "KO", (url, we)
|
||||||
print("[OK] ping results written back into the willexecutors mapping")
|
print("[OK] ping results written back into the willexecutors mapping")
|
||||||
finally:
|
finally:
|
||||||
W.get_info_task = orig_get_info
|
we_cls.get_info_task = orig_get_info
|
||||||
|
|
||||||
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
|
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
|
||||||
def slow_push(we, **kwargs):
|
def slow_push(we, **kwargs):
|
||||||
time.sleep(SLOW)
|
time.sleep(SLOW)
|
||||||
return "fail" not in we["url"]
|
return "fail" not in we["url"]
|
||||||
|
|
||||||
orig_push = W.push_transactions_to_willexecutor
|
orig_push = we_cls.push_transactions_to_willexecutor
|
||||||
W.push_transactions_to_willexecutor = staticmethod(slow_push)
|
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
|
||||||
try:
|
try:
|
||||||
wes = {}
|
wes = {}
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -106,7 +106,7 @@ def main():
|
|||||||
pushed.append((url, ok))
|
pushed.append((url, ok))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
results = W.push_transactions_parallel(wes, on_each=on_each_push,
|
results = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
|
||||||
max_workers=N)
|
max_workers=N)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
|
|
||||||
@@ -117,11 +117,11 @@ def main():
|
|||||||
f"(sequential would be ~{sequential:.2f}s)")
|
f"(sequential would be ~{sequential:.2f}s)")
|
||||||
|
|
||||||
assert len(results) == N, results
|
assert len(results) == N, results
|
||||||
for url, (ok, exc) in results.items():
|
for url, (ok, _exc) in results.items():
|
||||||
assert ok == ("good" in url), (url, ok)
|
assert ok == ("good" in url), (url, ok)
|
||||||
print("[OK] push results correct for every server")
|
print("[OK] push results correct for every server")
|
||||||
finally:
|
finally:
|
||||||
W.push_transactions_to_willexecutor = orig_push
|
we_cls.push_transactions_to_willexecutor = orig_push
|
||||||
|
|
||||||
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
|
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
|
||||||
def hanging_push(we, **kwargs):
|
def hanging_push(we, **kwargs):
|
||||||
@@ -129,8 +129,8 @@ def main():
|
|||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
orig_push2 = W.push_transactions_to_willexecutor
|
orig_push2 = we_cls.push_transactions_to_willexecutor
|
||||||
W.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
||||||
try:
|
try:
|
||||||
wes = {
|
wes = {
|
||||||
"https://fast.example": {
|
"https://fast.example": {
|
||||||
@@ -146,7 +146,7 @@ def main():
|
|||||||
return True
|
return True
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return True
|
return True
|
||||||
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
||||||
|
|
||||||
timed_out = []
|
timed_out = []
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ def main():
|
|||||||
timed_out.append(url)
|
timed_out.append(url)
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
W.push_transactions_parallel(
|
we_cls.push_transactions_parallel(
|
||||||
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
|
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
|
||||||
)
|
)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
@@ -163,7 +163,7 @@ def main():
|
|||||||
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
|
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
|
||||||
f"hung server reported via on_timeout")
|
f"hung server reported via on_timeout")
|
||||||
finally:
|
finally:
|
||||||
W.push_transactions_to_willexecutor = orig_push2
|
we_cls.push_transactions_to_willexecutor = orig_push2
|
||||||
|
|
||||||
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
|
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
|
||||||
# The elapsed-time counter is driven by an on_tick callback called from the
|
# The elapsed-time counter is driven by an on_tick callback called from the
|
||||||
@@ -175,8 +175,8 @@ def main():
|
|||||||
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
|
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
|
||||||
return True
|
return True
|
||||||
|
|
||||||
orig_push3 = W.push_transactions_to_willexecutor
|
orig_push3 = we_cls.push_transactions_to_willexecutor
|
||||||
W.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
||||||
try:
|
try:
|
||||||
wes = {
|
wes = {
|
||||||
"https://tick.example": {
|
"https://tick.example": {
|
||||||
@@ -191,7 +191,7 @@ def main():
|
|||||||
ticks.append(time.time())
|
ticks.append(time.time())
|
||||||
tick_threads.add(threading.current_thread())
|
tick_threads.add(threading.current_thread())
|
||||||
|
|
||||||
W.push_transactions_parallel(
|
we_cls.push_transactions_parallel(
|
||||||
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
|
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
|
||||||
)
|
)
|
||||||
# ~3s push with 0.5s ticks => at least a few ticks.
|
# ~3s push with 0.5s ticks => at least a few ticks.
|
||||||
@@ -202,7 +202,7 @@ def main():
|
|||||||
)
|
)
|
||||||
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
|
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
|
||||||
finally:
|
finally:
|
||||||
W.push_transactions_to_willexecutor = orig_push3
|
we_cls.push_transactions_to_willexecutor = orig_push3
|
||||||
|
|
||||||
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
|
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
|
||||||
# Pressing "Check" verifies each will-executor still holds its tx. This used
|
# Pressing "Check" verifies each will-executor still holds its tx. This used
|
||||||
@@ -214,8 +214,8 @@ def main():
|
|||||||
time.sleep(SLOW)
|
time.sleep(SLOW)
|
||||||
return {"tx": "ok"} if "good" in url else None
|
return {"tx": "ok"} if "good" in url else None
|
||||||
|
|
||||||
orig_check = W.check_transaction
|
orig_check = we_cls.check_transaction
|
||||||
W.check_transaction = staticmethod(slow_check)
|
we_cls.check_transaction = staticmethod(slow_check)
|
||||||
try:
|
try:
|
||||||
targets = []
|
targets = []
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -228,7 +228,7 @@ def main():
|
|||||||
checked.append((wid, res))
|
checked.append((wid, res))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
results = W.check_transactions_parallel(
|
results = we_cls.check_transactions_parallel(
|
||||||
targets, on_each=on_each_check, max_workers=N
|
targets, on_each=on_each_check, max_workers=N
|
||||||
)
|
)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
@@ -239,7 +239,7 @@ def main():
|
|||||||
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
|
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
|
||||||
f"(sequential would be ~{sequential:.2f}s)")
|
f"(sequential would be ~{sequential:.2f}s)")
|
||||||
finally:
|
finally:
|
||||||
W.check_transaction = orig_check
|
we_cls.check_transaction = orig_check
|
||||||
|
|
||||||
# 2d-bis) global deadline + on_tick from the calling thread
|
# 2d-bis) global deadline + on_tick from the calling thread
|
||||||
def hanging_check(txid, url, **kwargs):
|
def hanging_check(txid, url, **kwargs):
|
||||||
@@ -248,8 +248,8 @@ def main():
|
|||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return {"tx": "ok"}
|
return {"tx": "ok"}
|
||||||
|
|
||||||
orig_check2 = W.check_transaction
|
orig_check2 = we_cls.check_transaction
|
||||||
W.check_transaction = staticmethod(hanging_check)
|
we_cls.check_transaction = staticmethod(hanging_check)
|
||||||
try:
|
try:
|
||||||
targets = [
|
targets = [
|
||||||
("idf", "https://fast.example"),
|
("idf", "https://fast.example"),
|
||||||
@@ -268,7 +268,7 @@ def main():
|
|||||||
tick_threads.add(threading.current_thread())
|
tick_threads.add(threading.current_thread())
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
W.check_transactions_parallel(
|
we_cls.check_transactions_parallel(
|
||||||
targets, max_workers=2, deadline=2.0,
|
targets, max_workers=2, deadline=2.0,
|
||||||
on_timeout=on_timeout_check, on_tick=on_tick_check,
|
on_timeout=on_timeout_check, on_tick=on_tick_check,
|
||||||
tick_interval=0.5,
|
tick_interval=0.5,
|
||||||
@@ -282,7 +282,7 @@ def main():
|
|||||||
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
|
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
|
||||||
f"fired {len(ticks)}x from the calling thread")
|
f"fired {len(ticks)}x from the calling thread")
|
||||||
finally:
|
finally:
|
||||||
W.check_transaction = orig_check2
|
we_cls.check_transaction = orig_check2
|
||||||
|
|
||||||
# ---- 3) the wizard's loop_push must use the parallel helper ----
|
# ---- 3) the wizard's loop_push must use the parallel helper ----
|
||||||
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
|
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import sys
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
# Same colors as BalBuildWillDialog
|
# Same colors as BalBuildWillDialog
|
||||||
COLOR_WARNING = "#cfa808"
|
COLOR_WARNING = "#cfa808"
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
|
||||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
|
||||||
)
|
|
||||||
from PyQt6.QtCore import Qt # noqa: E402
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
|
QApplication,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
COLOR_OK = "#05ad05"
|
COLOR_OK = "#05ad05"
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import sys
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
COLOR_ERROR = "#ff0000"
|
COLOR_ERROR = "#ff0000"
|
||||||
COLOR_OK = "#05ad05"
|
COLOR_OK = "#05ad05"
|
||||||
|
|||||||
@@ -20,12 +20,17 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtCore import QSize # noqa: E402
|
||||||
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
|
|
||||||
QLabel,
|
|
||||||
)
|
|
||||||
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
||||||
from PyQt6.QtCore import QSize, Qt # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
|
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
|
||||||
"wizard.png")
|
"wizard.png")
|
||||||
|
|||||||
@@ -27,12 +27,19 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
|
||||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
|
|
||||||
QLineEdit, QSpinBox, QLabel,
|
|
||||||
)
|
|
||||||
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
|
||||||
from PyQt6.QtCore import Qt # noqa: E402
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
|
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QSpinBox,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _char_w():
|
def _char_w():
|
||||||
|
|||||||
194
tests/samanta7
194
tests/samanta7
@@ -65,7 +65,8 @@
|
|||||||
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl": [],
|
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl": [],
|
||||||
"bcrt1qwhjyltkpkyqj2k8zd6zjq2uu49tpu03f4sm7j0": [],
|
"bcrt1qwhjyltkpkyqj2k8zd6zjq2uu49tpu03f4sm7j0": [],
|
||||||
"bcrt1qygktltj8sjkus96k0atzgr8srcy658axnpj82x": [],
|
"bcrt1qygktltj8sjkus96k0atzgr8srcy658axnpj82x": [],
|
||||||
"bcrt1qyjpmlr7qehqedzhkzsx6xrvr9ey6455yur2pqx": []
|
"bcrt1qyjpmlr7qehqedzhkzsx6xrvr9ey6455yur2pqx": [],
|
||||||
|
"bcrt1qyx3e4qwguyr70g9wdva4dgpp2pdpauj9dht5ga": []
|
||||||
},
|
},
|
||||||
"addresses": {
|
"addresses": {
|
||||||
"change": [
|
"change": [
|
||||||
@@ -80,7 +81,8 @@
|
|||||||
"bcrt1q59nyxchtw4eajltn97sds6whdj4p2rg6uaygfq",
|
"bcrt1q59nyxchtw4eajltn97sds6whdj4p2rg6uaygfq",
|
||||||
"bcrt1qw3ljqdscx64qk9lxke3evz8scukj4zf8l5mptt",
|
"bcrt1qw3ljqdscx64qk9lxke3evz8scukj4zf8l5mptt",
|
||||||
"bcrt1qrkwdul9mws0hu298hvtd5usce9haypwmt8zhts",
|
"bcrt1qrkwdul9mws0hu298hvtd5usce9haypwmt8zhts",
|
||||||
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl"
|
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl",
|
||||||
|
"bcrt1qyx3e4qwguyr70g9wdva4dgpp2pdpauj9dht5ga"
|
||||||
],
|
],
|
||||||
"receiving": [
|
"receiving": [
|
||||||
"bcrt1qkxnsj8xymkk4mahea5ry8lgpau0w5dqgs0ezxc",
|
"bcrt1qkxnsj8xymkk4mahea5ry8lgpau0w5dqgs0ezxc",
|
||||||
@@ -121,22 +123,22 @@
|
|||||||
"aaaa": [
|
"aaaa": [
|
||||||
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
"34%",
|
"34%",
|
||||||
"5y"
|
1813204800
|
||||||
],
|
],
|
||||||
"lucia": [
|
"lucia": [
|
||||||
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
|
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
|
||||||
"32%",
|
"32%",
|
||||||
"5y"
|
1813204800
|
||||||
],
|
],
|
||||||
"mario": [
|
"mario": [
|
||||||
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
"35%",
|
"35%",
|
||||||
"5y"
|
1813204800
|
||||||
],
|
],
|
||||||
"mario2": [
|
"mario2": [
|
||||||
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
40000,
|
40000,
|
||||||
"5y"
|
1813204800
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"imported_channel_backups": {},
|
"imported_channel_backups": {},
|
||||||
@@ -152,6 +154,7 @@
|
|||||||
"xpub": "vpub5VpWCxVNP1qcP3aDcBMryrhaCECvFte8T3eT8BAUSGW4LuHoVEUNaKKPGsuN8H2MFTMmtZHnokVMB6c46MFiCzyYHwmsgAfr5B9DtFAEtjo"
|
"xpub": "vpub5VpWCxVNP1qcP3aDcBMryrhaCECvFte8T3eT8BAUSGW4LuHoVEUNaKKPGsuN8H2MFTMmtZHnokVMB6c46MFiCzyYHwmsgAfr5B9DtFAEtjo"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
|
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd": "BAL Inheritance transaction",
|
||||||
"4c955222d31ed19318841b7f3dd0e95edd6198ebddc0dbea1c249c38c609aeac": "BAL Invalidate transaction",
|
"4c955222d31ed19318841b7f3dd0e95edd6198ebddc0dbea1c249c38c609aeac": "BAL Invalidate transaction",
|
||||||
"5833dd0e77cc9e22d981b92e71e5f5804deb5294dfa4dc666cfe4fa015afee8d": "BAL Inheritance transaction",
|
"5833dd0e77cc9e22d981b92e71e5f5804deb5294dfa4dc666cfe4fa015afee8d": "BAL Inheritance transaction",
|
||||||
"5ca07e947509bea249d18591358c13a9c55e02b897390bab7ce0d2c6b2b394ec": "BAL Inheritance transaction",
|
"5ca07e947509bea249d18591358c13a9c55e02b897390bab7ce0d2c6b2b394ec": "BAL Inheritance transaction",
|
||||||
@@ -161,6 +164,7 @@
|
|||||||
"a45935c22968fe56dfdc329420336682cebfce0691ed764ab5891199ed91a158": "BAL Inheritance transaction",
|
"a45935c22968fe56dfdc329420336682cebfce0691ed764ab5891199ed91a158": "BAL Inheritance transaction",
|
||||||
"a526a6c1a74951df5bf5845f64dc97c3c9410935e2d05f6622a8f4ba31ec6811": "BAL Inheritance transaction",
|
"a526a6c1a74951df5bf5845f64dc97c3c9410935e2d05f6622a8f4ba31ec6811": "BAL Inheritance transaction",
|
||||||
"b45102630bd6f7b8774dff3ed39b393097e7eb6ae4714716f6e925497db4d6d9": "BAL Inheritance transaction",
|
"b45102630bd6f7b8774dff3ed39b393097e7eb6ae4714716f6e925497db4d6d9": "BAL Inheritance transaction",
|
||||||
|
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519": "BAL Inheritance transaction",
|
||||||
"e89b2329d4fe2b9349739b54720404f22d6b49ce94c297ce793f5e4e1f51672a": "BAL Invalidate transaction",
|
"e89b2329d4fe2b9349739b54720404f22d6b49ce94c297ce793f5e4e1f51672a": "BAL Invalidate transaction",
|
||||||
"f54f6c6a4bf6820b428ca5f6b7200545dacfd5e671e001c524888e502d007eaa": "BAL Invalidate transaction"
|
"f54f6c6a4bf6820b428ca5f6b7200545dacfd5e671e001c524888e502d007eaa": "BAL Invalidate transaction"
|
||||||
},
|
},
|
||||||
@@ -282,7 +286,7 @@
|
|||||||
"0": "7cb49f910a6db5d95436943456293aefbcccf0bbcc78b1dfe0adec430997fe07"
|
"0": "7cb49f910a6db5d95436943456293aefbcccf0bbcc78b1dfe0adec430997fe07"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stored_height": 2249,
|
"stored_height": 2336,
|
||||||
"submarine_swaps": {},
|
"submarine_swaps": {},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
"4c955222d31ed19318841b7f3dd0e95edd6198ebddc0dbea1c249c38c609aeac": "0200000000010108d37b8e201da3203b47c7cc33fdf21a55b9cf776fdf5d1a028cd250e953d9d20000000000fdffffff01ef56e44130000000160014f0df9afc9fdd8ec20c46eb6af56d3c4934063343024730440220625a4a9944af68bd2b4c4146ae9db95f052725b8ca4fc8e8fca541d1c863d37a022004a705b8974e5fea3707acd0d8cc5f9a9c898a508fd0ae348b81043c4a86ad0b0121021e753044de0b2d3894751fae6e62c0627d379752809e522a5d0d4e3689e4ba9fa9080000",
|
"4c955222d31ed19318841b7f3dd0e95edd6198ebddc0dbea1c249c38c609aeac": "0200000000010108d37b8e201da3203b47c7cc33fdf21a55b9cf776fdf5d1a028cd250e953d9d20000000000fdffffff01ef56e44130000000160014f0df9afc9fdd8ec20c46eb6af56d3c4934063343024730440220625a4a9944af68bd2b4c4146ae9db95f052725b8ca4fc8e8fca541d1c863d37a022004a705b8974e5fea3707acd0d8cc5f9a9c898a508fd0ae348b81043c4a86ad0b0121021e753044de0b2d3894751fae6e62c0627d379752809e522a5d0d4e3689e4ba9fa9080000",
|
||||||
@@ -393,6 +397,91 @@
|
|||||||
},
|
},
|
||||||
"wallet_type": "standard",
|
"wallet_type": "standard",
|
||||||
"will": {
|
"will": {
|
||||||
|
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd": {
|
||||||
|
"ANTICIPATED": false,
|
||||||
|
"BROADCASTED": false,
|
||||||
|
"CHECKED": true,
|
||||||
|
"CHECK_FAIL": false,
|
||||||
|
"COMPLETE": true,
|
||||||
|
"CONFIRMED": false,
|
||||||
|
"ERROR": false,
|
||||||
|
"EXPIRED": false,
|
||||||
|
"EXPORTED": false,
|
||||||
|
"IMPORTED": false,
|
||||||
|
"INVALIDATED": false,
|
||||||
|
"MEMPOOL": false,
|
||||||
|
"PUSHED": true,
|
||||||
|
"PUSH_FAIL": false,
|
||||||
|
"REPLACED": false,
|
||||||
|
"RESTORED": false,
|
||||||
|
"UPDATED": false,
|
||||||
|
"VALID": true,
|
||||||
|
"_id": "11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd",
|
||||||
|
"baltx_fees": 1,
|
||||||
|
"change": null,
|
||||||
|
"description": "w!ll3x3c\"http://localhost:9133\"1813204800\nmario2\naaaa\nlucia\nmario",
|
||||||
|
"heirs": {
|
||||||
|
"aaaa": [
|
||||||
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
|
"34%",
|
||||||
|
1813204800,
|
||||||
|
69771996601
|
||||||
|
],
|
||||||
|
"lucia": [
|
||||||
|
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
|
||||||
|
"32%",
|
||||||
|
1813204800,
|
||||||
|
65667761507
|
||||||
|
],
|
||||||
|
"mario": [
|
||||||
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
|
"35%",
|
||||||
|
1813204800,
|
||||||
|
71824114148
|
||||||
|
],
|
||||||
|
"mario2": [
|
||||||
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
|
40000,
|
||||||
|
1813204800,
|
||||||
|
40001,
|
||||||
|
40000
|
||||||
|
],
|
||||||
|
"w!ll3x3c\"http://localhost:9133\"1813204800": [
|
||||||
|
"bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
|
||||||
|
1000,
|
||||||
|
1813204800,
|
||||||
|
1000
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": "New.Firmato.Pushed.Checked",
|
||||||
|
"time": 1785536273.0165532,
|
||||||
|
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402201ce57302608a734f3de1b0d3b0eca08efb78f0d886180e7adf4897bfe4b4a49302202120e6f4308cef294094fa347ae7369eb0b0bb9b1bb64c8f03f50b349f01d7e1012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7404f136c",
|
||||||
|
"willexecutor": {
|
||||||
|
"address": "bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
|
||||||
|
"balance": 90000,
|
||||||
|
"base_fee": 1000,
|
||||||
|
"broadcast_status": "Riuscito",
|
||||||
|
"chain": "regtest",
|
||||||
|
"count_win": 0,
|
||||||
|
"id": 66,
|
||||||
|
"info": "BAL devel willexecutor server",
|
||||||
|
"last_block": 0,
|
||||||
|
"last_update": 1785463888.38115,
|
||||||
|
"onion_url": null,
|
||||||
|
"points": 0,
|
||||||
|
"promo_code": null,
|
||||||
|
"selected": true,
|
||||||
|
"status": 200,
|
||||||
|
"tld": "localhost",
|
||||||
|
"txs": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402201ce57302608a734f3de1b0d3b0eca08efb78f0d886180e7adf4897bfe4b4a49302202120e6f4308cef294094fa347ae7369eb0b0bb9b1bb64c8f03f50b349f01d7e1012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7404f136c\n",
|
||||||
|
"txsids": [
|
||||||
|
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd"
|
||||||
|
],
|
||||||
|
"unconfirmed_balance": 0,
|
||||||
|
"url": "http://localhost:9133",
|
||||||
|
"version": "0.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"6ad1383bb5d358c4b7bcebbf009d642e7a78b923cf3b764b196904c68ffa116d": {
|
"6ad1383bb5d358c4b7bcebbf009d642e7a78b923cf3b764b196904c68ffa116d": {
|
||||||
"ANTICIPATED": false,
|
"ANTICIPATED": false,
|
||||||
"BROADCASTED": false,
|
"BROADCASTED": false,
|
||||||
@@ -493,10 +582,10 @@
|
|||||||
"MEMPOOL": false,
|
"MEMPOOL": false,
|
||||||
"PUSHED": true,
|
"PUSHED": true,
|
||||||
"PUSH_FAIL": false,
|
"PUSH_FAIL": false,
|
||||||
"REPLACED": false,
|
"REPLACED": true,
|
||||||
"RESTORED": false,
|
"RESTORED": false,
|
||||||
"UPDATED": false,
|
"UPDATED": false,
|
||||||
"VALID": true,
|
"VALID": false,
|
||||||
"_id": "a45935c22968fe56dfdc329420336682cebfce0691ed764ab5891199ed91a158",
|
"_id": "a45935c22968fe56dfdc329420336682cebfce0691ed764ab5891199ed91a158",
|
||||||
"baltx_fees": 1,
|
"baltx_fees": 1,
|
||||||
"change": null,
|
"change": null,
|
||||||
@@ -534,7 +623,7 @@
|
|||||||
1000
|
1000
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"status": "New.Firmato.Pushed.Checked",
|
"status": "New.Firmato.Pushed.Checked.Replaced",
|
||||||
"time": 1785449918.3222272,
|
"time": 1785449918.3222272,
|
||||||
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402200b71f679baef994767c58cea234f2a2baa83cc029f64dae4ff6fe451aaffc369022051b2a70fd5c33fcc175d6dca4b052b4a7bc168c761434139560bee66ed965b18012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7c0cdd073",
|
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402200b71f679baef994767c58cea234f2a2baa83cc029f64dae4ff6fe451aaffc369022051b2a70fd5c33fcc175d6dca4b052b4a7bc168c761434139560bee66ed965b18012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7c0cdd073",
|
||||||
"willexecutor": {
|
"willexecutor": {
|
||||||
@@ -562,6 +651,91 @@
|
|||||||
"url": "http://localhost:9133",
|
"url": "http://localhost:9133",
|
||||||
"version": "0.3.2"
|
"version": "0.3.2"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519": {
|
||||||
|
"ANTICIPATED": false,
|
||||||
|
"BROADCASTED": false,
|
||||||
|
"CHECKED": true,
|
||||||
|
"CHECK_FAIL": false,
|
||||||
|
"COMPLETE": true,
|
||||||
|
"CONFIRMED": false,
|
||||||
|
"ERROR": false,
|
||||||
|
"EXPIRED": false,
|
||||||
|
"EXPORTED": false,
|
||||||
|
"IMPORTED": false,
|
||||||
|
"INVALIDATED": false,
|
||||||
|
"MEMPOOL": false,
|
||||||
|
"PUSHED": true,
|
||||||
|
"PUSH_FAIL": false,
|
||||||
|
"REPLACED": true,
|
||||||
|
"RESTORED": false,
|
||||||
|
"UPDATED": false,
|
||||||
|
"VALID": false,
|
||||||
|
"_id": "dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519",
|
||||||
|
"baltx_fees": 1,
|
||||||
|
"change": null,
|
||||||
|
"description": "w!ll3x3c\"http://localhost:9133\"1817006400\nmario2\naaaa\nlucia\nmario",
|
||||||
|
"heirs": {
|
||||||
|
"aaaa": [
|
||||||
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
|
"34%",
|
||||||
|
"1y",
|
||||||
|
69771996601
|
||||||
|
],
|
||||||
|
"lucia": [
|
||||||
|
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
|
||||||
|
"32%",
|
||||||
|
"1y",
|
||||||
|
65667761507
|
||||||
|
],
|
||||||
|
"mario": [
|
||||||
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
|
"35%",
|
||||||
|
"1y",
|
||||||
|
71824114148
|
||||||
|
],
|
||||||
|
"mario2": [
|
||||||
|
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
|
||||||
|
40000,
|
||||||
|
"1y",
|
||||||
|
40001,
|
||||||
|
40000
|
||||||
|
],
|
||||||
|
"w!ll3x3c\"http://localhost:9133\"1817006400": [
|
||||||
|
"bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
|
||||||
|
1000,
|
||||||
|
1817006400,
|
||||||
|
1000
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": "New.Firmato.Pushed.Checked.Replaced",
|
||||||
|
"time": 1785529939.6807153,
|
||||||
|
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402203e830a47f218826e2d1e4b708fdf4dc4a8826ac5c8a37a79fec5fbf1c6aa287002205df204067685f8874211ad092b0dad17a725f82a51e16f5c52db778135ecfbab012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b740514d6c",
|
||||||
|
"willexecutor": {
|
||||||
|
"address": "bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
|
||||||
|
"balance": 90000,
|
||||||
|
"base_fee": 1000,
|
||||||
|
"broadcast_status": "Riuscito",
|
||||||
|
"chain": "regtest",
|
||||||
|
"count_win": 0,
|
||||||
|
"id": 66,
|
||||||
|
"info": "BAL devel willexecutor server",
|
||||||
|
"last_block": 0,
|
||||||
|
"last_update": 1785463888.38115,
|
||||||
|
"onion_url": null,
|
||||||
|
"points": 0,
|
||||||
|
"promo_code": null,
|
||||||
|
"selected": true,
|
||||||
|
"status": 200,
|
||||||
|
"tld": "localhost",
|
||||||
|
"txs": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402203e830a47f218826e2d1e4b708fdf4dc4a8826ac5c8a37a79fec5fbf1c6aa287002205df204067685f8874211ad092b0dad17a725f82a51e16f5c52db778135ecfbab012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b740514d6c\n",
|
||||||
|
"txsids": [
|
||||||
|
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519"
|
||||||
|
],
|
||||||
|
"unconfirmed_balance": 0,
|
||||||
|
"url": "http://localhost:9133",
|
||||||
|
"version": "0.3.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"winpos-qt": [
|
"winpos-qt": [
|
||||||
|
|||||||
@@ -21,18 +21,21 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import copy
|
import copy
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
WillItem, Will,
|
HeirNotFoundException,
|
||||||
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
|
NoHeirsException,
|
||||||
TxFeesChangedException, WillExpiredException,
|
NotCompleteWillException,
|
||||||
|
TxFeesChangedException,
|
||||||
|
Will,
|
||||||
|
WillExpiredException,
|
||||||
|
WillItem,
|
||||||
)
|
)
|
||||||
from bal.core.util import Util
|
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
|
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
|
|||||||
@@ -26,30 +26,28 @@ def main():
|
|||||||
from PyQt6.QtWidgets import QApplication # noqa
|
from PyQt6.QtWidgets import QApplication # noqa
|
||||||
_app = QApplication.instance() or QApplication([])
|
_app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
# 1) Core modules import (these must be GUI-free).
|
# 1) Core modules import (these must be GUI-free).
|
||||||
bal = imp_core("bal", "core.plugin_base")
|
bal = imp_core("bal", "core.plugin_base")
|
||||||
util = imp_core("util", "core.util")
|
util = imp_core("util", "core.util")
|
||||||
heirs = imp_core("heirs", "core.heirs")
|
heirs = imp_core("heirs", "core.heirs")
|
||||||
will = imp_core("will", "core.will")
|
will = imp_core("will", "core.will")
|
||||||
we = imp_core("willexecutors", "core.willexecutors")
|
_we = imp_core("willexecutors", "core.willexecutors")
|
||||||
|
|
||||||
# 2) GUI module imports.
|
# 2) GUI module imports.
|
||||||
qt = imp_gui()
|
qt = imp_gui()
|
||||||
|
|
||||||
# 3) Behaviour checks (pure logic, must be identical across versions).
|
# 3) Behaviour checks (pure logic, must be identical across versions).
|
||||||
BalTimestamp = bal.BalTimestamp
|
bal_timestamp = bal.BalTimestamp
|
||||||
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
||||||
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
||||||
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
|
assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
|
||||||
|
|
||||||
Util = util.Util
|
util_cls = util.Util
|
||||||
assert Util.is_perc("50%") is True
|
assert util_cls.is_perc("50%") is True
|
||||||
assert Util.is_perc("100") is False
|
assert util_cls.is_perc("100") is False
|
||||||
assert Util.text_to_hex("BAL") == "42414c"
|
assert util_cls.text_to_hex("BAL") == "42414c"
|
||||||
assert Util.hex_to_text("42414c") == "BAL"
|
assert util_cls.hex_to_text("42414c") == "BAL"
|
||||||
assert Util.int_locktime(days=1) == 86400
|
assert util_cls.int_locktime(days=1) == 86400
|
||||||
|
|
||||||
# heirs constants must keep the same column layout (very delicate!)
|
# heirs constants must keep the same column layout (very delicate!)
|
||||||
assert heirs.HEIR_ADDRESS == 0
|
assert heirs.HEIR_ADDRESS == 0
|
||||||
|
|||||||
@@ -27,19 +27,19 @@ Run:
|
|||||||
tests/test_anticipate_manual_locktime.py -q
|
tests/test_anticipate_manual_locktime.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import copy
|
import copy
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
import pytest # noqa: E402
|
import pytest # noqa: E402
|
||||||
|
|
||||||
from bal.core.will import ( # noqa: E402
|
from bal.core.will import ( # noqa: E402
|
||||||
WillItem,
|
|
||||||
Will,
|
|
||||||
NotCompleteWillException,
|
NotCompleteWillException,
|
||||||
|
Will,
|
||||||
WillExpiredException,
|
WillExpiredException,
|
||||||
|
WillItem,
|
||||||
)
|
)
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2).
|
# A valid serialized tx (1 input + 1 output, version 2).
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ whether a fix is needed. Run:
|
|||||||
python3 -m pytest tests/test_anticipate_past_locktime.py -q
|
python3 -m pytest tests/test_anticipate_past_locktime.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
from bal.core.util import LOCKTIME_THRESHOLD, Util
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,25 +9,34 @@ Run:
|
|||||||
python3 tests/test_core_heirs.py
|
python3 tests/test_core_heirs.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.heirs import (
|
from bal.core.heirs import (
|
||||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
HEIR_ADDRESS,
|
||||||
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
|
HEIR_AMOUNT,
|
||||||
|
HEIR_DUST_AMOUNT,
|
||||||
|
HEIR_LOCKTIME,
|
||||||
|
HEIR_REAL_AMOUNT,
|
||||||
OP_RETURN_PREFIX,
|
OP_RETURN_PREFIX,
|
||||||
create_op_return_script,
|
TRANSACTION_LABEL,
|
||||||
is_op_return_address, get_op_return_hex, validate_op_return_hex,
|
|
||||||
AliasNotFoundException,
|
AliasNotFoundException,
|
||||||
NotAnAddress, AmountNotValid, LocktimeNotValid,
|
AmountNotValid,
|
||||||
HeirExpiredException, HeirAmountIsDustException,
|
|
||||||
NoHeirsException, WillExecutorFeeException,
|
|
||||||
BalanceTooLowException,
|
BalanceTooLowException,
|
||||||
|
HeirAmountIsDustException,
|
||||||
Heirs,
|
Heirs,
|
||||||
|
LocktimeNotValid,
|
||||||
|
NoHeirsException,
|
||||||
|
NotAnAddress,
|
||||||
|
WillExecutorFeeException,
|
||||||
|
create_op_return_script,
|
||||||
|
get_op_return_hex,
|
||||||
|
is_op_return_address,
|
||||||
|
validate_op_return_hex,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Constants
|
# Constants
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -70,7 +79,7 @@ def test_op_return_empty():
|
|||||||
def test_op_return_too_big():
|
def test_op_return_too_big():
|
||||||
try:
|
try:
|
||||||
create_op_return_script("ab" * 81) # 81 bytes > max 80
|
create_op_return_script("ab" * 81) # 81 bytes > max 80
|
||||||
assert False, "expected ValueError"
|
raise AssertionError("expected ValueError")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -179,13 +188,13 @@ def test_validate_amount():
|
|||||||
# Invalid
|
# Invalid
|
||||||
try:
|
try:
|
||||||
Heirs.validate_amount("0.000000001")
|
Heirs.validate_amount("0.000000001")
|
||||||
assert False, "expected AmountNotValid"
|
raise AssertionError("expected AmountNotValid")
|
||||||
except AmountNotValid:
|
except AmountNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Heirs.validate_amount("-1")
|
Heirs.validate_amount("-1")
|
||||||
assert False, "expected AmountNotValid"
|
raise AssertionError("expected AmountNotValid")
|
||||||
except AmountNotValid:
|
except AmountNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -209,7 +218,7 @@ def test_validate_locktime_expired():
|
|||||||
past = int(time.time()) - 86400 # yesterday
|
past = int(time.time()) - 86400 # yesterday
|
||||||
try:
|
try:
|
||||||
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
|
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
|
||||||
assert False, "expected LocktimeNotValid"
|
raise AssertionError("expected LocktimeNotValid")
|
||||||
except LocktimeNotValid:
|
except LocktimeNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -289,7 +298,7 @@ def test_validate_op_return_hex_valid():
|
|||||||
def test_validate_op_return_hex_invalid():
|
def test_validate_op_return_hex_invalid():
|
||||||
try:
|
try:
|
||||||
validate_op_return_hex("nothex!!")
|
validate_op_return_hex("nothex!!")
|
||||||
assert False, "expected NotAnAddress"
|
raise AssertionError("expected NotAnAddress")
|
||||||
except NotAnAddress:
|
except NotAnAddress:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -297,7 +306,7 @@ def test_validate_op_return_hex_invalid():
|
|||||||
def test_validate_op_return_hex_too_long():
|
def test_validate_op_return_hex_too_long():
|
||||||
try:
|
try:
|
||||||
validate_op_return_hex("ab" * 81)
|
validate_op_return_hex("ab" * 81)
|
||||||
assert False, "expected NotAnAddress"
|
raise AssertionError("expected NotAnAddress")
|
||||||
except NotAnAddress:
|
except NotAnAddress:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -355,4 +364,4 @@ if __name__ == "__main__":
|
|||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
globals()[name]()
|
globals()[name]()
|
||||||
print(f" [OK] {name}")
|
print(f" [OK] {name}")
|
||||||
print(f"[OK] All heirs tests passed")
|
print("[OK] All heirs tests passed")
|
||||||
|
|||||||
@@ -8,19 +8,20 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.heirs import (
|
from bal.core.heirs import (
|
||||||
Heirs, create_op_return_script, reduce_outputs,
|
HEIR_AMOUNT,
|
||||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
Heirs,
|
||||||
|
create_op_return_script,
|
||||||
|
reduce_outputs,
|
||||||
)
|
)
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Heirs db-dependent methods
|
# Heirs db-dependent methods
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -188,7 +189,7 @@ def test_validate_address_invalid():
|
|||||||
from bal.core.heirs import NotAnAddress
|
from bal.core.heirs import NotAnAddress
|
||||||
try:
|
try:
|
||||||
Heirs.validate_address("bad")
|
Heirs.validate_address("bad")
|
||||||
assert False, "should have raised"
|
raise AssertionError("should have raised")
|
||||||
except NotAnAddress:
|
except NotAnAddress:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ Run:
|
|||||||
python3 tests/test_core_plugin_base.py
|
python3 tests/test_core_plugin_base.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig
|
|
||||||
|
|
||||||
|
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# BalTimestamp
|
# BalTimestamp
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ Run:
|
|||||||
python3 tests/test_core_util.py
|
python3 tests/test_core_util.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
from bal.core.util import Util
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_to_str():
|
def test_locktime_to_str():
|
||||||
@@ -40,7 +41,7 @@ def test_str_to_locktime():
|
|||||||
|
|
||||||
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid
|
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid
|
||||||
# relative locktime, so it is NOT passed through unchanged.
|
# relative locktime, so it is NOT passed through unchanged.
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(ValueError):
|
||||||
Util.str_to_locktime("144b")
|
Util.str_to_locktime("144b")
|
||||||
|
|
||||||
# integer string -> int
|
# integer string -> int
|
||||||
@@ -347,44 +348,44 @@ def test_in_utxo():
|
|||||||
|
|
||||||
|
|
||||||
def test_cmp_output():
|
def test_cmp_output():
|
||||||
class O:
|
class Obj:
|
||||||
def __init__(self, addr, val):
|
def __init__(self, addr, val):
|
||||||
self.address = addr
|
self.address = addr
|
||||||
self.value = val
|
self.value = val
|
||||||
assert Util.cmp_output(O("a", 100), O("a", 100)) is True
|
assert Util.cmp_output(Obj("a", 100), Obj("a", 100)) is True
|
||||||
assert Util.cmp_output(O("a", 100), O("b", 100)) is False
|
assert Util.cmp_output(Obj("a", 100), Obj("b", 100)) is False
|
||||||
assert Util.cmp_output(O("a", 100), O("a", 200)) is False
|
assert Util.cmp_output(Obj("a", 100), Obj("a", 200)) is False
|
||||||
|
|
||||||
|
|
||||||
def test_in_output():
|
def test_in_output():
|
||||||
class O:
|
class Obj:
|
||||||
def __init__(self, addr, val):
|
def __init__(self, addr, val):
|
||||||
self.address = addr
|
self.address = addr
|
||||||
self.value = val
|
self.value = val
|
||||||
outputs = [O("a", 100), O("b", 200)]
|
outputs = [Obj("a", 100), Obj("b", 200)]
|
||||||
assert Util.in_output(O("a", 100), outputs) is True
|
assert Util.in_output(Obj("a", 100), outputs) is True
|
||||||
assert Util.in_output(O("z", 999), outputs) is False
|
assert Util.in_output(Obj("z", 999), outputs) is False
|
||||||
assert Util.in_output(O("a", 100), []) is False
|
assert Util.in_output(Obj("a", 100), []) is False
|
||||||
|
|
||||||
|
|
||||||
def test_din_output():
|
def test_din_output():
|
||||||
class O:
|
class Obj:
|
||||||
def __init__(self, addr, val):
|
def __init__(self, addr, val):
|
||||||
self.address = addr
|
self.address = addr
|
||||||
self.value = val
|
self.value = val
|
||||||
|
|
||||||
outputs = [O("a", 100), O("b", 200)]
|
outputs = [Obj("a", 100), Obj("b", 200)]
|
||||||
|
|
||||||
# same amount AND same address
|
# same amount AND same address
|
||||||
same_amt, same_addr = Util.din_output(O("a", 100), outputs)
|
same_amt, same_addr = Util.din_output(Obj("a", 100), outputs)
|
||||||
assert same_amt is True and same_addr is True
|
assert same_amt is True and same_addr is True
|
||||||
|
|
||||||
# same amount but different address
|
# same amount but different address
|
||||||
same_amt, same_addr = Util.din_output(O("c", 100), outputs)
|
same_amt, same_addr = Util.din_output(Obj("c", 100), outputs)
|
||||||
assert same_amt is True and same_addr is False
|
assert same_amt is True and same_addr is False
|
||||||
|
|
||||||
# different amount
|
# different amount
|
||||||
same_amt, same_addr = Util.din_output(O("z", 999), outputs)
|
same_amt, same_addr = Util.din_output(Obj("z", 999), outputs)
|
||||||
assert same_amt is False and same_addr is False
|
assert same_amt is False and same_addr is False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ Run:
|
|||||||
python3 tests/test_core_will.py
|
python3 tests/test_core_will.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import copy
|
import copy
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import WillItem, Will
|
from bal.core.will import Will, WillItem
|
||||||
from bal.core.willexecutors import Willexecutors
|
|
||||||
|
|
||||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -332,11 +332,19 @@ def test_will_check_tx_height():
|
|||||||
|
|
||||||
def test_exceptions():
|
def test_exceptions():
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
WillException, WillExpiredException, NotCompleteWillException,
|
AmountException,
|
||||||
HeirChangeException, TxFeesChangedException, HeirNotFoundException,
|
FixedAmountException,
|
||||||
WillexecutorChangeException, NoWillExecutorNotPresent,
|
HeirChangeException,
|
||||||
WillExecutorNotPresent, NoHeirsException,
|
HeirNotFoundException,
|
||||||
AmountException, PercAmountException, FixedAmountException,
|
NoHeirsException,
|
||||||
|
NotCompleteWillException,
|
||||||
|
NoWillExecutorNotPresent,
|
||||||
|
PercAmountException,
|
||||||
|
TxFeesChangedException,
|
||||||
|
WillException,
|
||||||
|
WillexecutorChangeException,
|
||||||
|
WillExecutorNotPresent,
|
||||||
|
WillExpiredException,
|
||||||
WillPostponedException,
|
WillPostponedException,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -375,4 +383,4 @@ if __name__ == "__main__":
|
|||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
globals()[name]()
|
globals()[name]()
|
||||||
print(f" [OK] {name}")
|
print(f" [OK] {name}")
|
||||||
print(f"[OK] All Will tests passed")
|
print("[OK] All Will tests passed")
|
||||||
|
|||||||
@@ -8,14 +8,28 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
from unittest.mock import MagicMock, patch, PropertyMock, call
|
import sys
|
||||||
|
from binascii import unhexlify
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum import crypto
|
||||||
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
|
from electrum.bitcoin import public_key_to_p2wpkh
|
||||||
|
from electrum.descriptor import parse_descriptor
|
||||||
|
from electrum.transaction import (
|
||||||
|
PartialTransaction,
|
||||||
|
PartialTxInput,
|
||||||
|
PartialTxOutput,
|
||||||
|
Sighash,
|
||||||
|
Transaction,
|
||||||
|
TxOutpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bal.core.util import Util
|
||||||
from bal.core.will import Will, WillItem
|
from bal.core.will import Will, WillItem
|
||||||
from electrum.transaction import Transaction
|
|
||||||
|
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
@@ -170,6 +184,8 @@ def test_mempool_status_clears_valid():
|
|||||||
def test_check_invalidated_invalidated():
|
def test_check_invalidated_invalidated():
|
||||||
wallet = MagicMock()
|
wallet = MagicMock()
|
||||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
|
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
|
||||||
|
# The funding is really consumed by a broadcast tx -> the will is dead.
|
||||||
|
wallet.adb.get_spender.return_value = "ab" * 32
|
||||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
"willexecutor": None, "status": "", "description": "",
|
"willexecutor": None, "status": "", "description": "",
|
||||||
"time": 0, "change": "", "baltx_fees": 100})
|
"time": 0, "change": "", "baltx_fees": 100})
|
||||||
@@ -185,6 +201,9 @@ def test_check_invalidated_invalidated():
|
|||||||
def test_check_will():
|
def test_check_will():
|
||||||
wallet = MagicMock()
|
wallet = MagicMock()
|
||||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
|
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
|
||||||
|
# No broadcast tx spends the funding: the missing UTXO is only a local
|
||||||
|
# (history) artifact, so the will is not invalidated by it.
|
||||||
|
wallet.adb.get_spender.return_value = None
|
||||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
"willexecutor": None, "status": "", "description": "",
|
"willexecutor": None, "status": "", "description": "",
|
||||||
"time": 0, "change": "", "baltx_fees": 100})
|
"time": 0, "change": "", "baltx_fees": 100})
|
||||||
@@ -196,6 +215,141 @@ def test_check_will():
|
|||||||
assert item.get_status("MEMPOOL") is True
|
assert item.get_status("MEMPOOL") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillItem signature counts + PARTIALLY_SIGNED status
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _multisig_descriptor():
|
||||||
|
"""Return a 2-of-3 wsh multisig descriptor and its three pubkeys."""
|
||||||
|
pubs = []
|
||||||
|
for seed in (1, 2, 3):
|
||||||
|
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
|
||||||
|
return parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs))), pubs
|
||||||
|
|
||||||
|
|
||||||
|
def _make_multisig_ptx(nsigs, locktime=None):
|
||||||
|
"""A 2-of-3 wsh multisig PartialTransaction carrying ``nsigs`` signatures."""
|
||||||
|
desc, pubs = _multisig_descriptor()
|
||||||
|
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
|
||||||
|
txin.script_descriptor = desc
|
||||||
|
txin._trusted_value_sats = 100000
|
||||||
|
txin.sighash = Sighash.ALL
|
||||||
|
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
|
||||||
|
for i in range(nsigs):
|
||||||
|
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
|
||||||
|
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
|
||||||
|
txout = PartialTxOutput.from_address_and_value(addr, 50000)
|
||||||
|
ptx = PartialTransaction()
|
||||||
|
if locktime is not None:
|
||||||
|
ptx.locktime = locktime
|
||||||
|
ptx.add_inputs([txin])
|
||||||
|
ptx.add_outputs([txout])
|
||||||
|
return ptx
|
||||||
|
|
||||||
|
|
||||||
|
def _make_multisig_willitem(nsig):
|
||||||
|
"""A WillItem wrapping an unsigned 2-of-3 partial tx with ``nsig`` sigs.
|
||||||
|
|
||||||
|
The script descriptor is re-attached after the WillItem round-trips the tx
|
||||||
|
through serialization (PSBT serialization drops the descriptor but keeps
|
||||||
|
the signatures), mirroring the real load-from-wallet flow.
|
||||||
|
"""
|
||||||
|
w = {"tx": _make_multisig_ptx(nsig), "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
|
"willexecutor": None, "status": "", "description": "",
|
||||||
|
"time": 0, "change": "", "baltx_fees": 100}
|
||||||
|
item = WillItem(w, _id="mswill")
|
||||||
|
item.tx.inputs()[0].script_descriptor = _multisig_descriptor()[0]
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def test_willitem_sigs_fields_roundtrip():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
assert item.sigs_required == 0
|
||||||
|
assert item.sigs_have == 0
|
||||||
|
item.sigs_required = 2
|
||||||
|
item.sigs_have = 1
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
d = item.to_dict()
|
||||||
|
assert d["sigs_required"] == 2
|
||||||
|
assert d["sigs_have"] == 1
|
||||||
|
assert d["PARTIALLY_SIGNED"] is True
|
||||||
|
item2 = WillItem(d, _id="mswill")
|
||||||
|
assert item2.sigs_required == 2
|
||||||
|
assert item2.sigs_have == 1
|
||||||
|
assert item2.get_status("PARTIALLY_SIGNED") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_willitem_legacy_dict_defaults_sig_fields():
|
||||||
|
# A will saved before the signature-tracking feature has no sig fields:
|
||||||
|
# they must default to 0 and the flag to False.
|
||||||
|
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
|
"willexecutor": None, "status": "", "description": "",
|
||||||
|
"time": 0, "change": "", "baltx_fees": 100}, _id="legacy")
|
||||||
|
assert item.sigs_required == 0
|
||||||
|
assert item.sigs_have == 0
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_willitem_partial_signed_keeps_valid():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is True
|
||||||
|
assert item.get_status("VALID") is True
|
||||||
|
assert "Partially Signed" in item.status
|
||||||
|
|
||||||
|
|
||||||
|
def test_willitem_complete_clears_partially_signed():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
item.set_status("COMPLETE", True)
|
||||||
|
assert item.get_status("COMPLETE") is True
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_partial():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.sigs_have == 1
|
||||||
|
assert item.sigs_required == 2
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is True
|
||||||
|
assert item.get_status("VALID") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_unsigned_not_partial():
|
||||||
|
item = _make_multisig_willitem(0)
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.sigs_have == 0
|
||||||
|
assert item.sigs_required == 2
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_fully_signed_clears_flag():
|
||||||
|
item = _make_multisig_willitem(2)
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_complete_item_clears_flag():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
item.set_status("COMPLETE", True)
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_single_sig_required():
|
||||||
|
# A single-signature (P2WPKH) will needs exactly 1 signature: 0 present is
|
||||||
|
# "New", not "partially signed".
|
||||||
|
pub = crypto.privkey_to_pubkey(bytes([7] * 32)).hex()
|
||||||
|
item = _make_multisig_willitem(0)
|
||||||
|
item.tx.inputs()[0].script_descriptor = parse_descriptor("wpkh({})".format(pub))
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.sigs_required == 1
|
||||||
|
assert item.sigs_have == 0
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# WillItem.__init__ with wallet
|
# WillItem.__init__ with wallet
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -217,6 +371,472 @@ def test_willitem_init_without_wallet():
|
|||||||
assert item is not None
|
assert item is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Will.save_valid_transactions_to_history (history persistence)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
class FakeTxMinedStatus:
|
||||||
|
def __init__(self, height):
|
||||||
|
self._height = height
|
||||||
|
|
||||||
|
def height(self):
|
||||||
|
return self._height
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTxInfo:
|
||||||
|
def __init__(self, height):
|
||||||
|
self.tx_mined_status = FakeTxMinedStatus(height)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWallet:
|
||||||
|
"""Minimal stand-in for an Electrum wallet used by history persistence."""
|
||||||
|
|
||||||
|
def __init__(self, stored_txs=None, spenders=None, heights=None, outputs=None,
|
||||||
|
addresses=None):
|
||||||
|
self.adb = FakeADB(
|
||||||
|
stored_txs or {},
|
||||||
|
spenders=spenders,
|
||||||
|
heights=heights,
|
||||||
|
outputs=outputs,
|
||||||
|
)
|
||||||
|
self.db = self.adb.db
|
||||||
|
self.labels = {}
|
||||||
|
self.save_db_called = 0
|
||||||
|
self.addresses = list(addresses or [])
|
||||||
|
|
||||||
|
def set_label(self, txid, label):
|
||||||
|
if label is None:
|
||||||
|
self.labels.pop(txid, None)
|
||||||
|
else:
|
||||||
|
self.labels[txid] = label
|
||||||
|
|
||||||
|
def get_all_labels(self):
|
||||||
|
return dict(self.labels)
|
||||||
|
|
||||||
|
def save_db(self):
|
||||||
|
self.save_db_called += 1
|
||||||
|
|
||||||
|
def get_addresses(self):
|
||||||
|
return self.addresses
|
||||||
|
|
||||||
|
def get_label_for_txid(self, txid):
|
||||||
|
return self.labels.get(txid, "")
|
||||||
|
|
||||||
|
def get_tx_info(self, tx):
|
||||||
|
height = self.adb.heights.get(tx.txid(), TX_HEIGHT_LOCAL)
|
||||||
|
return FakeTxInfo(height)
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
utxos = []
|
||||||
|
for outs in self.adb.outputs.values():
|
||||||
|
for utxo in outs.values():
|
||||||
|
if utxo.spent_height is None:
|
||||||
|
utxos.append(utxo)
|
||||||
|
return utxos
|
||||||
|
|
||||||
|
|
||||||
|
class FakeADB:
|
||||||
|
def __init__(self, stored_txs, spenders=None, heights=None, outputs=None):
|
||||||
|
self.db = FakeDB(stored_txs)
|
||||||
|
self.added = []
|
||||||
|
self.removed = []
|
||||||
|
self.spenders = dict(spenders or {})
|
||||||
|
self.heights = dict(heights or {})
|
||||||
|
self.outputs = dict(outputs or {})
|
||||||
|
|
||||||
|
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
|
||||||
|
self.added.append((tx, allow_unrelated))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_transaction(self, txid):
|
||||||
|
self.removed.append(txid)
|
||||||
|
|
||||||
|
def get_spender(self, outpoint):
|
||||||
|
txid = self.spenders.get(outpoint)
|
||||||
|
if txid is None:
|
||||||
|
return None
|
||||||
|
height = self.heights.get(txid, TX_HEIGHT_LOCAL)
|
||||||
|
if height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
|
||||||
|
return None
|
||||||
|
return txid
|
||||||
|
|
||||||
|
def get_tx_height(self, txid):
|
||||||
|
return FakeTxMinedStatus(self.heights.get(txid, TX_HEIGHT_LOCAL))
|
||||||
|
|
||||||
|
def get_addr_outputs(self, addr):
|
||||||
|
return self.outputs.get(addr, {})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
def __init__(self, stored_txs):
|
||||||
|
self.stored = dict(stored_txs)
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return self.stored.get(txid)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_simple_willitem(tx, valid=True, we_url=None):
|
||||||
|
"""A WillItem wrapping *tx* with an optional VALID status and executor."""
|
||||||
|
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
|
"willexecutor": {"url": we_url} if we_url else None, "status": "",
|
||||||
|
"description": "", "time": 0, "change": "", "baltx_fees": 100,
|
||||||
|
"VALID": valid}
|
||||||
|
return WillItem(w, _id="wid")
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_label(template, url):
|
||||||
|
return template.replace("{willexecutor}", url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_incomplete_valid_tx_to_history_adds_and_labels():
|
||||||
|
# A still-unsigned / partially-signed ("New") partial tx is stored in the
|
||||||
|
# local history, tagged with the decoded label.
|
||||||
|
tx = _make_multisig_ptx(1)
|
||||||
|
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
|
||||||
|
wallet = FakeWallet()
|
||||||
|
Will.save_valid_transactions_to_history(
|
||||||
|
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
assert [t.txid() for t, _ in wallet.adb.added] == [tx.txid()]
|
||||||
|
txid = tx.txid()
|
||||||
|
assert wallet.labels[txid] == "BitcoinAfterLife inheritance transaction - https://we.example"
|
||||||
|
assert wallet.save_db_called == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_skips_complete_and_invalid_items():
|
||||||
|
# A fully-signed ("Complete") tx must NOT be stored: it is removed from the
|
||||||
|
# local history instead. Invalid items are never touched.
|
||||||
|
complete = _make_multisig_willitem(2)
|
||||||
|
complete.set_status("VALID", True)
|
||||||
|
invalid = _make_simple_willitem(Transaction(_VALID_TX_HEX), valid=False)
|
||||||
|
new = _make_simple_willitem(_make_multisig_ptx(1), valid=True)
|
||||||
|
wallet = FakeWallet()
|
||||||
|
Will.save_valid_transactions_to_history(
|
||||||
|
{"complete": complete, "invalid": invalid, "new": new},
|
||||||
|
wallet,
|
||||||
|
"BitcoinAfterLife inheritance transaction - {willexecutor}",
|
||||||
|
)
|
||||||
|
assert [t.txid() for t, _ in wallet.adb.added] == [new.tx.txid()]
|
||||||
|
assert len(wallet.labels) == 1
|
||||||
|
assert wallet.labels[new.tx.txid()] == (
|
||||||
|
"BitcoinAfterLife inheritance transaction - "
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_combines_sigs_when_stored_partial():
|
||||||
|
# The tx to save is already present in the wallet as an incomplete partial
|
||||||
|
# PSBT: the signatures are combined into it instead of blindly overwriting.
|
||||||
|
# (Exercised on raw PartialTransactions; through WillItem a complete tx
|
||||||
|
# round-trips to a plain Transaction, which overwrites instead - see
|
||||||
|
# test_save_complete_tx_overwrites_stored_partial.)
|
||||||
|
our = _make_multisig_ptx(2)
|
||||||
|
stored_partial = _make_multisig_ptx(1)
|
||||||
|
wallet = FakeWallet(stored_txs={our.txid(): stored_partial})
|
||||||
|
Will._add_transaction_to_history(wallet, our, our.txid())
|
||||||
|
assert len(wallet.adb.added) == 1
|
||||||
|
saved, _ = wallet.adb.added[0]
|
||||||
|
# The combine path was taken (the stored partial was re-added, not our tx).
|
||||||
|
assert saved is stored_partial
|
||||||
|
assert saved.is_complete()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_removes_complete_item_from_history():
|
||||||
|
# A fully-signed item is removed from the local history: its matching
|
||||||
|
# entry (exact label) is deleted, and it is never re-added.
|
||||||
|
our = _make_multisig_ptx(2)
|
||||||
|
txid = our.txid()
|
||||||
|
label = "BitcoinAfterLife inheritance transaction - https://we.example"
|
||||||
|
item = _make_simple_willitem(our, valid=True, we_url="https://we.example")
|
||||||
|
wallet = FakeWallet(stored_txs={txid: our})
|
||||||
|
wallet.labels[txid] = label
|
||||||
|
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
|
||||||
|
assert wallet.adb.added == []
|
||||||
|
assert txid in wallet.adb.removed
|
||||||
|
assert txid not in wallet.labels
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cleanup_removes_stale_exact_label():
|
||||||
|
tx = _make_multisig_ptx(1)
|
||||||
|
txid = tx.txid()
|
||||||
|
stale_txid = "ab" * 32
|
||||||
|
other_txid = "cd" * 32
|
||||||
|
label = "BitcoinAfterLife inheritance transaction - https://we.example"
|
||||||
|
wallet = FakeWallet()
|
||||||
|
# Pre-existing wallet labels: one current, one stale (same label), one with
|
||||||
|
# a different executor URL that must be kept.
|
||||||
|
wallet.labels[txid] = label
|
||||||
|
wallet.labels[stale_txid] = label
|
||||||
|
wallet.labels[other_txid] = "BitcoinAfterLife inheritance transaction - https://other.example"
|
||||||
|
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
|
||||||
|
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
|
||||||
|
assert stale_txid in wallet.adb.removed
|
||||||
|
assert other_txid not in wallet.adb.removed
|
||||||
|
assert txid not in wallet.adb.removed
|
||||||
|
# The stale tx's label is dropped with it.
|
||||||
|
assert stale_txid not in wallet.labels
|
||||||
|
assert other_txid in wallet.labels
|
||||||
|
assert wallet.labels[txid] == label
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_no_wallet_or_no_adb_is_noop():
|
||||||
|
tx = Transaction(_VALID_TX_HEX)
|
||||||
|
item = _make_simple_willitem(tx, valid=True)
|
||||||
|
Will.save_valid_transactions_to_history({"wid": item}, None, "LBL")
|
||||||
|
Will.save_valid_transactions_to_history({"wid": item}, object(), "LBL")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_never_raises_on_adb_failure():
|
||||||
|
tx = Transaction(_VALID_TX_HEX)
|
||||||
|
item = _make_simple_willitem(tx, valid=True)
|
||||||
|
|
||||||
|
class BoomWallet(FakeWallet):
|
||||||
|
class BoomADB:
|
||||||
|
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
def remove_transaction(self, txid):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.adb = self.BoomADB()
|
||||||
|
self.labels = {}
|
||||||
|
|
||||||
|
def get_all_labels(self):
|
||||||
|
return {"stale": "BitcoinAfterLife inheritance transaction - "}
|
||||||
|
|
||||||
|
wallet = BoomWallet()
|
||||||
|
Will.save_valid_transactions_to_history(
|
||||||
|
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
# No exception propagates; a fresh fake works afterwards.
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_will_does_not_save_to_history():
|
||||||
|
# History persistence is no longer triggered from check_will: it runs after
|
||||||
|
# the will is signed (see the GUI hooks). check_will must not touch it.
|
||||||
|
with patch.object(Will, "save_valid_transactions_to_history") as save_mock:
|
||||||
|
Will.check_will({}, [], None, 9999999999)
|
||||||
|
save_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_will_valid_calls_check_will_without_history_label():
|
||||||
|
with patch.object(Will, "check_will") as cw_mock:
|
||||||
|
Will.is_will_valid({}, 9999999999, 100, [])
|
||||||
|
assert len(cw_mock.call_args[0]) == 4
|
||||||
|
assert cw_mock.call_args[0] == ({}, [], False, 9999999999)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Signature absorption + status fixes (local-history will tx)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _make_willitem_keyed_by_txid(tx, valid=True, we_url=None):
|
||||||
|
"""A WillItem whose ``_id`` equals its txid (as in a real built will).
|
||||||
|
|
||||||
|
The script descriptor is re-attached after the WillItem round-trips the tx
|
||||||
|
through serialization, mirroring the real load-from-wallet flow.
|
||||||
|
"""
|
||||||
|
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
|
"willexecutor": {"url": we_url} if we_url else None, "status": "",
|
||||||
|
"description": "", "time": 0, "change": "", "baltx_fees": 100,
|
||||||
|
"VALID": valid}
|
||||||
|
item = WillItem(w, _id=tx.txid())
|
||||||
|
if isinstance(tx, PartialTransaction) and tx.inputs():
|
||||||
|
desc = getattr(tx.inputs()[0], "script_descriptor", None)
|
||||||
|
if desc is not None and isinstance(item.tx, PartialTransaction):
|
||||||
|
item.tx.inputs()[0].script_descriptor = desc
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _make_local_wallet(tx, stored, funding):
|
||||||
|
"""A FakeWallet where *tx* is stored locally and consumes *funding*."""
|
||||||
|
return FakeWallet(
|
||||||
|
stored_txs={tx.txid(): stored},
|
||||||
|
spenders={funding: tx.txid()},
|
||||||
|
heights={tx.txid(): TX_HEIGHT_LOCAL},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_absorb_history_signatures_merges_and_completes():
|
||||||
|
# The wallet's stored local copy of the will tx carries more signatures
|
||||||
|
# than the in-memory item: they are merged in and the item becomes COMPLETE.
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
stored = _make_multisig_ptx(2)
|
||||||
|
assert stored.txid() == item.tx.txid()
|
||||||
|
wallet = FakeWallet(stored_txs={item._id: stored})
|
||||||
|
Will._absorb_history_signatures({item._id: item}, wallet)
|
||||||
|
assert item.tx.is_complete() is True
|
||||||
|
assert item.get_status("COMPLETE") is True
|
||||||
|
assert item.get_status("VALID") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_absorb_history_signatures_noop_without_stored_copy():
|
||||||
|
# Nothing stored in the wallet: the in-memory item is left untouched.
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
wallet = FakeWallet()
|
||||||
|
Will._absorb_history_signatures({item._id: item}, wallet)
|
||||||
|
assert item.tx.is_complete() is False
|
||||||
|
assert item.get_status("COMPLETE") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_invalidated_keeps_valid_on_local_spend():
|
||||||
|
# The funding is missing from the wallet's UTXOs only because the will tx
|
||||||
|
# itself was saved into the local history: a wallet-local spender must not
|
||||||
|
# invalidate the will.
|
||||||
|
tx = _make_multisig_ptx(1)
|
||||||
|
item = _make_willitem_keyed_by_txid(tx)
|
||||||
|
will = {tx.txid(): item}
|
||||||
|
funding = tx.inputs()[0].prevout.to_str()
|
||||||
|
wallet = _make_local_wallet(tx, tx, funding)
|
||||||
|
Will.check_invalidated(will, [], wallet)
|
||||||
|
assert item.get_status("INVALIDATED") is False
|
||||||
|
assert item.get_status("VALID") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_invalidated_invalidates_on_real_spend():
|
||||||
|
# The funding is consumed by a broadcast transaction: the will is dead.
|
||||||
|
tx = _make_multisig_ptx(1)
|
||||||
|
item = _make_willitem_keyed_by_txid(tx)
|
||||||
|
will = {tx.txid(): item}
|
||||||
|
funding = tx.inputs()[0].prevout.to_str()
|
||||||
|
ext_spender = "ab" * 32
|
||||||
|
wallet = FakeWallet(
|
||||||
|
spenders={funding: ext_spender},
|
||||||
|
heights={ext_spender: 100},
|
||||||
|
)
|
||||||
|
Will.check_invalidated(will, [], wallet)
|
||||||
|
assert item.get_status("INVALIDATED") is True
|
||||||
|
assert item.get_status("VALID") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_rai_local_artifact_keeps_valid():
|
||||||
|
tx = _make_multisig_ptx(1)
|
||||||
|
item = _make_willitem_keyed_by_txid(tx)
|
||||||
|
will = {tx.txid(): item}
|
||||||
|
funding = tx.inputs()[0].prevout.to_str()
|
||||||
|
wallet = _make_local_wallet(tx, tx, funding)
|
||||||
|
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
|
||||||
|
assert item.get_status("VALID") is True
|
||||||
|
assert item.get_status("INVALIDATED") is False
|
||||||
|
assert item.get_status("CONFIRMED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_rai_confirmed_on_broadcast_spender():
|
||||||
|
# The will tx is broadcast/confirmed: its own (real) spender marks it
|
||||||
|
# CONFIRMED, not INVALIDATED.
|
||||||
|
tx = _make_multisig_ptx(1)
|
||||||
|
item = _make_willitem_keyed_by_txid(tx)
|
||||||
|
will = {tx.txid(): item}
|
||||||
|
funding = tx.inputs()[0].prevout.to_str()
|
||||||
|
wallet = FakeWallet(
|
||||||
|
stored_txs={tx.txid(): tx},
|
||||||
|
spenders={funding: tx.txid()},
|
||||||
|
heights={tx.txid(): 100},
|
||||||
|
)
|
||||||
|
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
|
||||||
|
assert item.get_status("CONFIRMED") is True
|
||||||
|
assert item.get_status("INVALIDATED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_will_merges_history_sigs_and_stays_valid():
|
||||||
|
# End-to-end: a will tx stored in the local history gained a signature.
|
||||||
|
# check_will must absorb it, not invalidate the will for the local spend.
|
||||||
|
now = 1700000000
|
||||||
|
locktime = 2000000000
|
||||||
|
tx = _make_multisig_ptx(1, locktime)
|
||||||
|
stored = _make_multisig_ptx(2, locktime)
|
||||||
|
assert stored.txid() == tx.txid()
|
||||||
|
item = _make_willitem_keyed_by_txid(tx)
|
||||||
|
will = {tx.txid(): item}
|
||||||
|
funding = tx.inputs()[0].prevout.to_str()
|
||||||
|
wallet = _make_local_wallet(tx, stored, funding)
|
||||||
|
Will.check_will(will, [], wallet, now)
|
||||||
|
assert item.get_status("COMPLETE") is True
|
||||||
|
assert item.get_status("VALID") is True
|
||||||
|
assert item.get_status("INVALIDATED") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Util.get_available_utxos (UTXO-view restoration)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _make_utxo(prevout_hex="22", idx=0, value=100000,
|
||||||
|
spent_txid=None, spent_height=None):
|
||||||
|
txin = PartialTxInput(
|
||||||
|
prevout=TxOutpoint(bytes.fromhex(prevout_hex) * 32, idx), script_sig=b""
|
||||||
|
)
|
||||||
|
txin._trusted_value_sats = value
|
||||||
|
txin.spent_txid = spent_txid
|
||||||
|
txin.spent_height = spent_height
|
||||||
|
return txin
|
||||||
|
|
||||||
|
|
||||||
|
_HISTORY_TEMPLATE = "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
_HISTORY_LABEL = _HISTORY_TEMPLATE.replace("{willexecutor}", "https://we.example")
|
||||||
|
|
||||||
|
|
||||||
|
def _wallet_with_local_spend(locktime):
|
||||||
|
addr = "bcrt1qexample"
|
||||||
|
spender = "ab" * 32
|
||||||
|
utxo = _make_utxo(spent_txid=spender, spent_height=TX_HEIGHT_LOCAL)
|
||||||
|
wallet = FakeWallet(
|
||||||
|
stored_txs={spender: _make_multisig_ptx(0, locktime=locktime)},
|
||||||
|
heights={spender: TX_HEIGHT_LOCAL},
|
||||||
|
outputs={addr: {utxo.prevout.to_str(): utxo}},
|
||||||
|
addresses=[addr],
|
||||||
|
)
|
||||||
|
wallet.labels[spender] = _HISTORY_LABEL
|
||||||
|
return wallet, utxo
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_utxos_restores_future_bal_local_spend():
|
||||||
|
# A later-locktime BAL history tx locally spent the coin: it is restored.
|
||||||
|
wallet, utxo = _wallet_with_local_spend(locktime=2000)
|
||||||
|
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
|
||||||
|
assert [u.prevout.to_str() for u in result] == [utxo.prevout.to_str()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_utxos_does_not_restore_unlabeled_spend():
|
||||||
|
wallet, utxo = _wallet_with_local_spend(locktime=2000)
|
||||||
|
wallet.labels["ab" * 32] = "some other label"
|
||||||
|
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_utxos_does_not_restore_not_later_locktime():
|
||||||
|
# The stored spender's locktime equals the will's locktime (same will):
|
||||||
|
# its spend is NOT ignored.
|
||||||
|
wallet, utxo = _wallet_with_local_spend(locktime=1000)
|
||||||
|
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_utxos_does_not_restore_confirmed_spend():
|
||||||
|
# A broadcast (confirmed) spender is never ignored.
|
||||||
|
addr = "bcrt1qexample"
|
||||||
|
spender = "ab" * 32
|
||||||
|
utxo = _make_utxo(spent_txid=spender, spent_height=100)
|
||||||
|
wallet = FakeWallet(
|
||||||
|
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
|
||||||
|
heights={spender: 100},
|
||||||
|
outputs={addr: {utxo.prevout.to_str(): utxo}},
|
||||||
|
addresses=[addr],
|
||||||
|
)
|
||||||
|
wallet.labels[spender] = _HISTORY_LABEL
|
||||||
|
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_utxos_none_locktime_is_raw_view():
|
||||||
|
# No reference locktime: the raw wallet.get_utxos() view is returned, so a
|
||||||
|
# locally-spent coin stays hidden.
|
||||||
|
wallet, utxo = _wallet_with_local_spend(locktime=2000)
|
||||||
|
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, None)
|
||||||
|
assert result == []
|
||||||
|
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -22,15 +22,14 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
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
|
# Patch Transaction.add_info_from_wallet so WillItem can parse the tx hex
|
||||||
# without a live Electrum wallet connection.
|
# without a live Electrum wallet connection.
|
||||||
from electrum.transaction import Transaction
|
from electrum.transaction import Transaction
|
||||||
|
|
||||||
|
from bal.core.will import Will, WillItem
|
||||||
|
|
||||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||||
_patcher.start()
|
_patcher.start()
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
from bal.core.plugin_base import BalConfig
|
from bal.core.plugin_base import BalConfig
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Mocks
|
# Mocks
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
|
|
||||||
from bal.core.plugin_base import BalConfig
|
from bal.core.plugin_base import BalConfig
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Mocks
|
# Mocks
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -65,6 +64,47 @@ def test_editable_dates_can_be_enabled():
|
|||||||
assert BalConfig(cfg, "bal_editable_dates", False).get() is True
|
assert BalConfig(cfg, "bal_editable_dates", False).get() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# History persistence settings (SAVE_HISTORY / HISTORY_LABEL)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_save_history_defaults_on():
|
||||||
|
"""History persistence is opt-out: the flag defaults to ON."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
save_history = BalConfig(cfg, "bal_save_history", True)
|
||||||
|
assert save_history.get() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_history_can_be_disabled_and_read_back():
|
||||||
|
cfg = FakeConfig()
|
||||||
|
save_history = BalConfig(cfg, "bal_save_history", True)
|
||||||
|
save_history.set(False)
|
||||||
|
assert BalConfig(cfg, "bal_save_history", True).get() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_label_default_template():
|
||||||
|
"""The default label contains the {willexecutor} variable."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
history_label = BalConfig(
|
||||||
|
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
assert history_label.get() == (
|
||||||
|
"BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
assert "{willexecutor}" in history_label.get()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_label_can_be_changed_and_read_back():
|
||||||
|
cfg = FakeConfig()
|
||||||
|
history_label = BalConfig(
|
||||||
|
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
history_label.set("My custom label for {willexecutor}")
|
||||||
|
assert BalConfig(
|
||||||
|
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
).get() == "My custom label for {willexecutor}"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# C4b - Reset to defaults
|
# C4b - Reset to defaults
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -83,9 +123,10 @@ def _reset_to_defaults(configs):
|
|||||||
def test_reset_restores_all_dialog_settings():
|
def test_reset_restores_all_dialog_settings():
|
||||||
"""C4b: Reset restores every dialog setting to its factory default.
|
"""C4b: Reset restores every dialog setting to its factory default.
|
||||||
|
|
||||||
The dialog exposes seven settings: the original six plus the Group C
|
The dialog exposes nine settings: the original six, the Group C
|
||||||
"Editable dates" checkbox, which the Reset button must also restore (this
|
"Editable dates" checkbox, and the Group H "Save inheritance transactions
|
||||||
was a follow-up fix after the first test round).
|
in history" checkbox + "History label" field, which the Reset button must
|
||||||
|
also restore.
|
||||||
"""
|
"""
|
||||||
cfg = FakeConfig()
|
cfg = FakeConfig()
|
||||||
|
|
||||||
@@ -103,6 +144,10 @@ def test_reset_restores_all_dialog_settings():
|
|||||||
"bal_event_description",
|
"bal_event_description",
|
||||||
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
||||||
)
|
)
|
||||||
|
save_history = BalConfig(cfg, "bal_save_history", True)
|
||||||
|
history_label = BalConfig(
|
||||||
|
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
settings = [
|
settings = [
|
||||||
hide_replaced,
|
hide_replaced,
|
||||||
hide_invalidated,
|
hide_invalidated,
|
||||||
@@ -111,6 +156,8 @@ def test_reset_restores_all_dialog_settings():
|
|||||||
calendar_app,
|
calendar_app,
|
||||||
event_summary,
|
event_summary,
|
||||||
event_description,
|
event_description,
|
||||||
|
save_history,
|
||||||
|
history_label,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Mutate every setting away from its default.
|
# Mutate every setting away from its default.
|
||||||
@@ -121,11 +168,15 @@ def test_reset_restores_all_dialog_settings():
|
|||||||
calendar_app.set("/custom/app")
|
calendar_app.set("/custom/app")
|
||||||
event_summary.set("custom summary")
|
event_summary.set("custom summary")
|
||||||
event_description.set("custom description")
|
event_description.set("custom description")
|
||||||
|
save_history.set(False)
|
||||||
|
history_label.set("custom label")
|
||||||
|
|
||||||
# Sanity: the values really changed.
|
# Sanity: the values really changed.
|
||||||
assert hide_replaced.get() is False
|
assert hide_replaced.get() is False
|
||||||
assert editable_dates.get() is True
|
assert editable_dates.get() is True
|
||||||
assert calendar_app.get() == "/custom/app"
|
assert calendar_app.get() == "/custom/app"
|
||||||
|
assert save_history.get() is False
|
||||||
|
assert history_label.get() == "custom label"
|
||||||
|
|
||||||
# Reset and verify each one is back to its declared default.
|
# Reset and verify each one is back to its declared default.
|
||||||
_reset_to_defaults(settings)
|
_reset_to_defaults(settings)
|
||||||
@@ -133,6 +184,11 @@ def test_reset_restores_all_dialog_settings():
|
|||||||
assert s.get() == s.default
|
assert s.get() == s.default
|
||||||
# In particular the "Editable dates" flag is back OFF.
|
# In particular the "Editable dates" flag is back OFF.
|
||||||
assert editable_dates.get() is False
|
assert editable_dates.get() is False
|
||||||
|
# And the history persistence flag is back ON with the default template.
|
||||||
|
assert save_history.get() is True
|
||||||
|
assert history_label.get() == (
|
||||||
|
"BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_reset_does_not_touch_unrelated_settings():
|
def test_reset_does_not_touch_unrelated_settings():
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
from bal.core.plugin_base import BalConfig
|
from bal.core.plugin_base import BalConfig
|
||||||
from bal.gui.qt.widgets import compute_reminder_offsets
|
from bal.gui.qt.widgets import compute_reminder_offsets
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Mocks
|
# Mocks
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -42,15 +41,12 @@ from electrum import bitcoin
|
|||||||
from electrum.transaction import (
|
from electrum.transaction import (
|
||||||
PartialTransaction,
|
PartialTransaction,
|
||||||
PartialTxInput,
|
PartialTxInput,
|
||||||
PartialTxOutput,
|
|
||||||
TxOutpoint,
|
TxOutpoint,
|
||||||
)
|
)
|
||||||
from electrum.util import bfh
|
from electrum.util import bfh
|
||||||
|
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
from bal.core.will import Will, WillItem
|
from bal.core.will import Will, WillItem
|
||||||
from bal.core.willexecutors import Willexecutors
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Load karen7 wallet data
|
# Load karen7 wallet data
|
||||||
@@ -166,7 +162,7 @@ def _build_utxo_value_map(data):
|
|||||||
for txid, outputs in txo.items():
|
for txid, outputs in txo.items():
|
||||||
if not isinstance(outputs, dict):
|
if not isinstance(outputs, dict):
|
||||||
continue
|
continue
|
||||||
for addr, out_map in outputs.items():
|
for _, out_map in outputs.items():
|
||||||
if not isinstance(out_map, dict):
|
if not isinstance(out_map, dict):
|
||||||
continue
|
continue
|
||||||
for idx, info in out_map.items():
|
for idx, info in out_map.items():
|
||||||
@@ -188,7 +184,7 @@ def _populate_input_values(will, utxo_value_map):
|
|||||||
This is the equivalent of what ``add_info_from_wallet`` does in the
|
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.
|
real flow: looking up the UTXO value and attaching it to the input.
|
||||||
"""
|
"""
|
||||||
for wid, wi in will.items():
|
for _, wi in will.items():
|
||||||
for txin in wi.tx.inputs():
|
for txin in wi.tx.inputs():
|
||||||
prevout_str = txin.prevout.to_str()
|
prevout_str = txin.prevout.to_str()
|
||||||
if txin._trusted_value_sats is None and prevout_str in utxo_value_map:
|
if txin._trusted_value_sats is None and prevout_str in utxo_value_map:
|
||||||
|
|||||||
@@ -36,13 +36,12 @@ import pytest
|
|||||||
# below the repo root that contains the ``bal`` package).
|
# below the repo root that contains the ``bal`` package).
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import WillItem, Will, HeirNotFoundException
|
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
|
from bal.core.will import HeirNotFoundException, Will, WillItem
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
from bal.gui.qt.calendar import BalCalendar
|
from bal.gui.qt.calendar import BalCalendar
|
||||||
from bal.gui.qt.widgets import compute_reminder_offsets
|
from bal.gui.qt.widgets import compute_reminder_offsets
|
||||||
|
|
||||||
|
|
||||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
||||||
# version 2). Reused from test_core_will.py so WillItem can parse a real tx.
|
# version 2). Reused from test_core_will.py so WillItem can parse a real tx.
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -550,7 +549,7 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
|
|||||||
for txid, outputs in txo.items():
|
for txid, outputs in txo.items():
|
||||||
if not isinstance(outputs, dict):
|
if not isinstance(outputs, dict):
|
||||||
continue
|
continue
|
||||||
for addr, out_map in outputs.items():
|
for _, out_map in outputs.items():
|
||||||
if not isinstance(out_map, dict):
|
if not isinstance(out_map, dict):
|
||||||
continue
|
continue
|
||||||
for idx, info in out_map.items():
|
for idx, info in out_map.items():
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ Run:
|
|||||||
python3 tests/test_group_f_heir_change_rebuild.py
|
python3 tests/test_group_f_heir_change_rebuild.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import Will
|
from bal.core.will import Will
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import sys
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.gui.qt.widgets import (BASIC_REMINDER_OFFSETS,
|
from bal.gui.qt.widgets import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
|
||||||
basic_reminder_offsets)
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_offsets_all_future():
|
def test_basic_offsets_all_future():
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
|
|
||||||
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
|
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
|
||||||
|
|
||||||
|
|
||||||
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
|
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ Run:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from bal.gui.qt.calendar import BalCalendar
|
from bal.gui.qt.calendar import BalCalendar
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# format_time
|
# format_time
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ Run:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
|
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel
|
||||||
|
|
||||||
# Import the module itself, not via "from .common import *"
|
# Import the module itself, not via "from .common import *"
|
||||||
import bal.gui.qt.common as C
|
import bal.gui.qt.common as common
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
@@ -23,18 +24,18 @@ _app = QApplication.instance() or QApplication(sys.argv)
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_shown_cv_default():
|
def test_shown_cv_default():
|
||||||
cv = C.shown_cv(True)
|
cv = common.shown_cv(True)
|
||||||
assert cv.get() is True
|
assert cv.get() is True
|
||||||
|
|
||||||
|
|
||||||
def test_shown_cv_set():
|
def test_shown_cv_set():
|
||||||
cv = C.shown_cv(True)
|
cv = common.shown_cv(True)
|
||||||
cv.set(False)
|
cv.set(False)
|
||||||
assert cv.get() is False
|
assert cv.get() is False
|
||||||
|
|
||||||
|
|
||||||
def test_shown_cv_roundtrip():
|
def test_shown_cv_roundtrip():
|
||||||
cv = C.shown_cv(False)
|
cv = common.shown_cv(False)
|
||||||
assert cv.get() is False
|
assert cv.get() is False
|
||||||
cv.set(True)
|
cv.set(True)
|
||||||
assert cv.get() is True
|
assert cv.get() is True
|
||||||
@@ -47,19 +48,19 @@ def test_shown_cv_roundtrip():
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_check_alive_error_default():
|
def test_check_alive_error_default():
|
||||||
err = C.CheckAliveError(1000000)
|
err = common.CheckAliveError(1000000)
|
||||||
assert err.timestamp_to_check == 1000000
|
assert err.timestamp_to_check == 1000000
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_str():
|
def test_check_alive_error_str():
|
||||||
err = C.CheckAliveError(1000000)
|
err = common.CheckAliveError(1000000)
|
||||||
s = str(err)
|
s = str(err)
|
||||||
assert "Check alive expired" in s
|
assert "Check alive expired" in s
|
||||||
assert "1970" in s
|
assert "1970" in s
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_subclass():
|
def test_check_alive_error_subclass():
|
||||||
assert issubclass(C.CheckAliveError, Exception)
|
assert issubclass(common.CheckAliveError, Exception)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -68,17 +69,15 @@ def test_check_alive_error_subclass():
|
|||||||
|
|
||||||
def test_add_widget():
|
def test_add_widget():
|
||||||
grid = QGridLayout()
|
grid = QGridLayout()
|
||||||
parent = QWidget()
|
|
||||||
label = QLabel("test")
|
label = QLabel("test")
|
||||||
C.add_widget(grid, "Label", label, 0, "Help text")
|
common.add_widget(grid, "Label", label, 0, "Help text")
|
||||||
assert grid.count() == 3 # label + widget + help button
|
assert grid.count() == 3 # label + widget + help button
|
||||||
|
|
||||||
|
|
||||||
def test_add_widget_multiple_rows():
|
def test_add_widget_multiple_rows():
|
||||||
grid = QGridLayout()
|
grid = QGridLayout()
|
||||||
parent = QWidget()
|
common.add_widget(grid, "A", QLabel("a"), 0, "help_a")
|
||||||
C.add_widget(grid, "A", QLabel("a"), 0, "help_a")
|
common.add_widget(grid, "B", QLabel("b"), 1, "help_b")
|
||||||
C.add_widget(grid, "B", QLabel("b"), 1, "help_b")
|
|
||||||
assert grid.count() == 6
|
assert grid.count() == 6
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ def test_add_widget_multiple_rows():
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_log_error_no_window():
|
def test_log_error_no_window():
|
||||||
C.log_error((Exception, Exception("test"), None))
|
common.log_error((Exception, Exception("test"), None))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
216
tests/test_gui_prepare_will_history.py
Normal file
216
tests/test_gui_prepare_will_history.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Tests for the history-save hook in ``BalWindow``.
|
||||||
|
|
||||||
|
Verifies that every successful prepare (including the "Prepare" menu action)
|
||||||
|
persists the freshly prepared transactions into the wallet's local history,
|
||||||
|
while abort paths (which return ``None``) skip the save. Also verifies that
|
||||||
|
after any local-history change (saving or removing will transactions) the
|
||||||
|
wallet tabs are re-rendered through ``update_tabs`` (all tabs) plus
|
||||||
|
``update_status`` (status-bar balance), and that the rebuild path uses the same
|
||||||
|
full refresh.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source electrum/env/bin/activate
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_prepare_will_history.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import bal.gui.qt.window as win_mod
|
||||||
|
from bal.core.util import Util
|
||||||
|
from bal.core.will import NotCompleteWillException, Will
|
||||||
|
from bal.gui.qt.window import BalWindow
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# prepare_will -> _save_will_to_history
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_prepare_will_saves_to_history_on_success():
|
||||||
|
win = object.__new__(BalWindow)
|
||||||
|
will = {"wid": object()}
|
||||||
|
with (
|
||||||
|
patch.object(BalWindow, "build_inheritance_transaction", return_value=will)
|
||||||
|
as build_mock,
|
||||||
|
patch.object(BalWindow, "_save_will_to_history") as save_mock,
|
||||||
|
):
|
||||||
|
result = BalWindow.prepare_will(win)
|
||||||
|
assert result is will
|
||||||
|
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
|
||||||
|
save_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_will_skips_save_when_build_aborted():
|
||||||
|
win = object.__new__(BalWindow)
|
||||||
|
with (
|
||||||
|
patch.object(BalWindow, "build_inheritance_transaction", return_value=None)
|
||||||
|
as build_mock,
|
||||||
|
patch.object(BalWindow, "_save_will_to_history") as save_mock,
|
||||||
|
):
|
||||||
|
result = BalWindow.prepare_will(win)
|
||||||
|
assert result is None
|
||||||
|
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
|
||||||
|
save_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# history refresh: _save_will_to_history -> update_tabs + update_status
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
class _Cfg:
|
||||||
|
def __init__(self, value):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
|
||||||
|
class _CfgBag:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
for name, value in kwargs.items():
|
||||||
|
setattr(self, name, value)
|
||||||
|
|
||||||
|
|
||||||
|
class _Wallet:
|
||||||
|
def dust_threshold(self):
|
||||||
|
return 546
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWindow:
|
||||||
|
def __init__(self, *, with_update_tabs=True):
|
||||||
|
self.show_message = Mock()
|
||||||
|
self.update_status = Mock()
|
||||||
|
self.history_list = Mock()
|
||||||
|
self.history_list.update = Mock()
|
||||||
|
if with_update_tabs:
|
||||||
|
self.update_tabs = Mock()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQTimer:
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def singleShot(cls, delay, callable_):
|
||||||
|
cls.calls.append((delay, callable_))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_save_window(save_enabled):
|
||||||
|
win = object.__new__(BalWindow)
|
||||||
|
win.bal_plugin = _CfgBag(
|
||||||
|
SAVE_HISTORY=_Cfg(save_enabled), HISTORY_LABEL=_Cfg("LBL")
|
||||||
|
)
|
||||||
|
win.willitems = {"wid": object()}
|
||||||
|
win.wallet = object()
|
||||||
|
return win
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_will_to_history_schedules_refresh_when_enabled():
|
||||||
|
win = _make_save_window(save_enabled=True)
|
||||||
|
with (
|
||||||
|
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
|
||||||
|
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
|
||||||
|
):
|
||||||
|
BalWindow._save_will_to_history(win)
|
||||||
|
save_mock.assert_called_once_with(win.willitems, win.wallet, "LBL")
|
||||||
|
schedule_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_will_to_history_skips_save_and_refresh_when_disabled():
|
||||||
|
win = _make_save_window(save_enabled=False)
|
||||||
|
with (
|
||||||
|
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
|
||||||
|
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
|
||||||
|
):
|
||||||
|
BalWindow._save_will_to_history(win)
|
||||||
|
save_mock.assert_not_called()
|
||||||
|
schedule_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_will_to_history_schedules_refresh_even_on_error():
|
||||||
|
win = _make_save_window(save_enabled=True)
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
Will,
|
||||||
|
"save_valid_transactions_to_history",
|
||||||
|
side_effect=RuntimeError("boom"),
|
||||||
|
),
|
||||||
|
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
|
||||||
|
):
|
||||||
|
BalWindow._save_will_to_history(win)
|
||||||
|
schedule_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_history_refresh_marshals_to_gui_thread():
|
||||||
|
win = object.__new__(BalWindow)
|
||||||
|
with patch.object(win_mod, "QTimer", _FakeQTimer):
|
||||||
|
_FakeQTimer.calls.clear()
|
||||||
|
BalWindow._schedule_history_refresh(win)
|
||||||
|
assert _FakeQTimer.calls == [(0, win._refresh_after_history_save)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_after_history_save_calls_update_tabs_and_status():
|
||||||
|
win = object.__new__(BalWindow)
|
||||||
|
win.window = _FakeWindow(with_update_tabs=True)
|
||||||
|
BalWindow._refresh_after_history_save(win)
|
||||||
|
win.window.update_tabs.assert_called_once_with()
|
||||||
|
win.window.update_status.assert_called_once_with()
|
||||||
|
win.window.history_list.update.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_after_history_save_falls_back_to_history_list():
|
||||||
|
win = object.__new__(BalWindow)
|
||||||
|
win.window = _FakeWindow(with_update_tabs=False)
|
||||||
|
BalWindow._refresh_after_history_save(win)
|
||||||
|
win.window.history_list.update.assert_called_once_with()
|
||||||
|
win.window.update_status.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_path_schedules_full_refresh():
|
||||||
|
win = object.__new__(BalWindow)
|
||||||
|
win.disable_plugin = False
|
||||||
|
win.heirs = {"h": object()}
|
||||||
|
win.willexecutors = {}
|
||||||
|
win.no_willexecutor = True
|
||||||
|
win.willitems = {}
|
||||||
|
win.will = {}
|
||||||
|
win.date_to_check = 1_800_000_000
|
||||||
|
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
|
||||||
|
win.bal_plugin = _CfgBag(
|
||||||
|
MAX_WILLEXECUTOR_FEE=_Cfg(1),
|
||||||
|
SAVE_HISTORY=_Cfg(True),
|
||||||
|
HISTORY_LABEL=_Cfg("LBL"),
|
||||||
|
)
|
||||||
|
win.window = _FakeWindow()
|
||||||
|
win.window.wallet = _Wallet()
|
||||||
|
with (
|
||||||
|
patch.object(Util, "get_available_utxos", return_value=[]),
|
||||||
|
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
|
||||||
|
patch.object(Will, "get_min_locktime", return_value=0),
|
||||||
|
patch.object(Will, "check_amounts"),
|
||||||
|
patch.object(BalWindow, "init_class_variables"),
|
||||||
|
patch.object(BalWindow, "build_will"),
|
||||||
|
patch.object(
|
||||||
|
BalWindow,
|
||||||
|
"check_will",
|
||||||
|
side_effect=[NotCompleteWillException(), None],
|
||||||
|
),
|
||||||
|
patch.object(BalWindow, "update_all"),
|
||||||
|
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
|
||||||
|
):
|
||||||
|
BalWindow.build_inheritance_transaction(win)
|
||||||
|
schedule_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All prepare-will history tests passed")
|
||||||
@@ -8,16 +8,19 @@ Run:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from bal.gui.qt.theme import status_color
|
from bal.gui.qt.theme import signature_suffix, status_color
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
class FakeWillItem:
|
class FakeWillItem:
|
||||||
|
sigs_required = 0
|
||||||
|
sigs_have = 0
|
||||||
|
|
||||||
def __init__(self, **status_flags):
|
def __init__(self, **status_flags):
|
||||||
self._status = dict(status_flags)
|
self._status = dict(status_flags)
|
||||||
def get_status(self, name):
|
def get_status(self, name):
|
||||||
@@ -97,6 +100,54 @@ def test_color_check_fail_overrides_push_fail():
|
|||||||
assert status_color(item) == "#e83845"
|
assert status_color(item) == "#e83845"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# PARTIALLY_SIGNED
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_color_partially_signed():
|
||||||
|
assert status_color(FakeWillItem(PARTIALLY_SIGNED=True)) == "#ffb347"
|
||||||
|
|
||||||
|
|
||||||
|
def test_color_partially_signed_overridden_by_higher_priority():
|
||||||
|
item = FakeWillItem(PARTIALLY_SIGNED=True, PUSHED=True)
|
||||||
|
assert status_color(item) == "#73f3c8"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# signature_suffix
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_signature_suffix_partial():
|
||||||
|
item = FakeWillItem(PARTIALLY_SIGNED=True)
|
||||||
|
item.sigs_required = 2
|
||||||
|
item.sigs_have = 1
|
||||||
|
assert signature_suffix(item) == " (1/2)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_suffix_new():
|
||||||
|
item = FakeWillItem()
|
||||||
|
item.sigs_required = 2
|
||||||
|
item.sigs_have = 0
|
||||||
|
assert signature_suffix(item) == " (0/2)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_suffix_complete_empty():
|
||||||
|
item = FakeWillItem(COMPLETE=True)
|
||||||
|
item.sigs_required = 2
|
||||||
|
item.sigs_have = 1
|
||||||
|
assert signature_suffix(item) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_suffix_unknown_required_empty():
|
||||||
|
item = FakeWillItem()
|
||||||
|
item.sigs_have = 1
|
||||||
|
assert signature_suffix(item) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_suffix_missing_fields_empty():
|
||||||
|
assert signature_suffix(FakeWillItem()) == ""
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -13,13 +13,11 @@ Run:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from PyQt6.QtCore import QTimer
|
|
||||||
from PyQt6.QtWidgets import QApplication, QWidget
|
from PyQt6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
from electrum.util import DECIMAL_POINT, decimal_point_to_base_unit_name
|
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,18 @@ Run:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from PyQt6.QtCore import QTimer
|
from PyQt6.QtCore import QTimer
|
||||||
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
||||||
|
|
||||||
from bal.gui.qt.window_utils import (
|
from bal.gui.qt.window_utils import (
|
||||||
bring_to_front, show_modal, show_on_top, stop_thread, top_level_of,
|
bring_to_front,
|
||||||
|
show_modal,
|
||||||
|
show_on_top,
|
||||||
|
stop_thread,
|
||||||
|
top_level_of,
|
||||||
)
|
)
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|||||||
559
tests/test_import_will_details.py
Normal file
559
tests/test_import_will_details.py
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
"""
|
||||||
|
Tests for the "Import" (read-only will preview) flow.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- ``BalWindow._load_will_file`` round-trip (file -> WillItems).
|
||||||
|
- ``import_will_into_details`` normalization + IMPORTED status.
|
||||||
|
- ``BalWindow.sign_transactions`` operating ONLY on the passed (imported)
|
||||||
|
will, never on the live wallet state.
|
||||||
|
- ``WillWidget`` honouring an explicit ``will`` argument.
|
||||||
|
- ``WillDetailDialog`` external-will mode (threshold + isolated buttons).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source /home/steal/devel/bal/electrum/env/bin/activate
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_import_will_details.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from types import MethodType, SimpleNamespace
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
|
from bal.core.will import Will, WillItem
|
||||||
|
from bal.gui.qt import window as window_mod
|
||||||
|
|
||||||
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_willitem_dict(**overrides):
|
||||||
|
"""Return a minimal dict that can construct a WillItem."""
|
||||||
|
d = {
|
||||||
|
"tx": _VALID_TX_HEX,
|
||||||
|
"heirs": {},
|
||||||
|
"willexecutor": None,
|
||||||
|
"status": "",
|
||||||
|
"description": "",
|
||||||
|
"time": 0,
|
||||||
|
"change": "",
|
||||||
|
"baltx_fees": 100,
|
||||||
|
}
|
||||||
|
d.update(overrides)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _make_willitems(n, prefix="w"):
|
||||||
|
"""Create ``n`` WillItems with distinct txids/locktimes."""
|
||||||
|
willitems = {}
|
||||||
|
for i in range(n):
|
||||||
|
wid = f"{prefix}{i}"
|
||||||
|
wi = WillItem(_make_willitem_dict())
|
||||||
|
wi.tx.locktime = 1000 + i
|
||||||
|
wi._id = wid
|
||||||
|
willitems[wid] = wi
|
||||||
|
return willitems
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _load_will_file round-trip
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_load_will_file_roundtrip():
|
||||||
|
from bal.gui.qt.common import write_json_file
|
||||||
|
|
||||||
|
src = _make_willitems(2)
|
||||||
|
data = {wid: wi.to_dict() for wid, wi in src.items()}
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False
|
||||||
|
) as f:
|
||||||
|
path = f.name
|
||||||
|
write_json_file(path, data)
|
||||||
|
try:
|
||||||
|
loaded = window_mod.BalWindow._load_will_file(None, path)
|
||||||
|
assert set(loaded) == {"w0", "w1"}
|
||||||
|
for wid, wi in loaded.items():
|
||||||
|
assert isinstance(wi, WillItem)
|
||||||
|
assert wi.heirs == {}
|
||||||
|
assert wi._id == wid
|
||||||
|
assert wi.tx is not None
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# import_will_into_details normalization + IMPORTED status
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_imported_will_is_normalized_and_marked_imported():
|
||||||
|
willitems = _make_willitems(1)
|
||||||
|
Will.normalize_will(willitems, None)
|
||||||
|
for wi in willitems.values():
|
||||||
|
wi.set_status("IMPORTED", True)
|
||||||
|
assert all(wi.get_status("IMPORTED") for wi in willitems.values())
|
||||||
|
assert all(wi.get_status("VALID") for wi in willitems.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# sign_transactions operates only on the passed (imported) will
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_sign_transactions_external_only():
|
||||||
|
class FakeWallet:
|
||||||
|
def sign_transaction(self, tx, password, ignore_warnings=False):
|
||||||
|
# No-op: never marks the tx complete.
|
||||||
|
return None
|
||||||
|
|
||||||
|
live = _make_willitems(1, prefix="live")
|
||||||
|
wid_live = next(iter(live))
|
||||||
|
imported = _make_willitems(2, prefix="imp")
|
||||||
|
|
||||||
|
fake = SimpleNamespace(
|
||||||
|
willitems=live,
|
||||||
|
wallet=FakeWallet(),
|
||||||
|
waiting_dialog=SimpleNamespace(update=lambda msg: None),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert set(result) == set(imported)
|
||||||
|
assert wid_live not in result
|
||||||
|
# The live will must be completely untouched by the external sign run.
|
||||||
|
assert live[wid_live].get_status("COMPLETE") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillWidget honours an explicit ``will`` argument
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_will_widget_explicit_will():
|
||||||
|
from bal.gui.qt.widgets import WillWidget
|
||||||
|
|
||||||
|
live = _make_willitems(1, prefix="live")
|
||||||
|
imported = _make_willitems(2, prefix="imp")
|
||||||
|
|
||||||
|
fake_parent = SimpleNamespace(
|
||||||
|
decimal_point=8,
|
||||||
|
base_unit_name="BTC",
|
||||||
|
bal_window=SimpleNamespace(
|
||||||
|
willitems=live,
|
||||||
|
bal_plugin=SimpleNamespace(
|
||||||
|
_hide_replaced=False, _hide_invalidated=False
|
||||||
|
),
|
||||||
|
show_transaction=lambda *a, **k: None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
w = WillWidget(parent=fake_parent, will=imported)
|
||||||
|
assert w.will is imported
|
||||||
|
|
||||||
|
w2 = WillWidget(parent=fake_parent)
|
||||||
|
assert w2.will is live
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillDetailDialog external-will mode
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _make_fake_bal_window(window_widget):
|
||||||
|
bal_plugin = SimpleNamespace(read_file=lambda path: b"")
|
||||||
|
return SimpleNamespace(
|
||||||
|
window=window_widget,
|
||||||
|
bal_plugin=bal_plugin,
|
||||||
|
wallet=SimpleNamespace(),
|
||||||
|
show_transaction=lambda *a, **k: None,
|
||||||
|
willitems=_make_willitems(1),
|
||||||
|
will_settings={"real_threshold": 9999},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_detail_dialog_external_threshold():
|
||||||
|
from bal.gui.qt.dialogs import WillDetailDialog
|
||||||
|
|
||||||
|
window_widget = QWidget()
|
||||||
|
bal_window = _make_fake_bal_window(window_widget)
|
||||||
|
bal_window.window.config = SimpleNamespace()
|
||||||
|
bal_window.window.format_amount = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.base_unit = "BTC"
|
||||||
|
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.fx = None
|
||||||
|
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.get_decimal_point = lambda: 8
|
||||||
|
|
||||||
|
imported = _make_willitems(2)
|
||||||
|
# locktimes 1000 and 1001 -> threshold must be the max (1001).
|
||||||
|
dialog = WillDetailDialog(bal_window, will=imported)
|
||||||
|
|
||||||
|
assert dialog._external_will is True
|
||||||
|
assert dialog.will is imported
|
||||||
|
assert dialog.threshold == 1001
|
||||||
|
|
||||||
|
dialog2 = WillDetailDialog(bal_window)
|
||||||
|
assert dialog2._external_will is False
|
||||||
|
assert dialog2.will is bal_window.willitems
|
||||||
|
assert dialog2.threshold == 9999
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_detail_dialog_buttons_pass_will():
|
||||||
|
from bal.gui.qt.dialogs import WillDetailDialog
|
||||||
|
|
||||||
|
window_widget = QWidget()
|
||||||
|
bal_window = _make_fake_bal_window(window_widget)
|
||||||
|
bal_window.window.config = SimpleNamespace()
|
||||||
|
bal_window.window.format_amount = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.base_unit = "BTC"
|
||||||
|
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.fx = None
|
||||||
|
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.get_decimal_point = lambda: 8
|
||||||
|
|
||||||
|
imported = _make_willitems(1)
|
||||||
|
dialog = WillDetailDialog(bal_window, will=imported)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
bal_window.ask_password_and_sign_transactions = lambda **k: calls.append(
|
||||||
|
("sign", k.get("will"))
|
||||||
|
)
|
||||||
|
bal_window.broadcast_transactions = lambda **k: calls.append(
|
||||||
|
("broadcast", k.get("will"))
|
||||||
|
)
|
||||||
|
bal_window.export_will = lambda **k: calls.append(("export", k.get("will")))
|
||||||
|
bal_window.invalidate_will = lambda **k: calls.append(
|
||||||
|
("invalidate", k.get("will"))
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog.ask_password_and_sign_transactions()
|
||||||
|
dialog.broadcast_transactions()
|
||||||
|
dialog.export_will()
|
||||||
|
dialog.invalidate_will()
|
||||||
|
|
||||||
|
assert len(calls) == 4
|
||||||
|
for action, will in calls:
|
||||||
|
assert will is imported, f"{action} did not receive the imported will"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Merge flow (BalWindow.merge_will)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _make_partial_tx(locktime=1000, signed=False):
|
||||||
|
"""A PartialTransaction derived from _VALID_TX_HEX.
|
||||||
|
|
||||||
|
When ``signed`` the input scriptSig of the raw tx is copied over, which
|
||||||
|
finalizes the (legacy P2PKH) input and makes ``is_complete()`` True.
|
||||||
|
"""
|
||||||
|
from electrum.transaction import PartialTransaction, Transaction
|
||||||
|
|
||||||
|
ptx = PartialTransaction.from_tx(Transaction(_VALID_TX_HEX))
|
||||||
|
ptx.locktime = locktime
|
||||||
|
if signed:
|
||||||
|
raw = Transaction(_VALID_TX_HEX)
|
||||||
|
ptx.inputs()[0].script_sig = raw.inputs()[0].script_sig
|
||||||
|
return ptx
|
||||||
|
|
||||||
|
|
||||||
|
def _make_willitem_with_tx(tx, key=None):
|
||||||
|
wi = WillItem(_make_willitem_dict())
|
||||||
|
wi.tx = tx
|
||||||
|
wi._id = key if key is not None else tx.txid()
|
||||||
|
return wi
|
||||||
|
|
||||||
|
|
||||||
|
def _make_merge_fake(willitems):
|
||||||
|
"""A BalWindow-like object with a wallet stub sufficient for the local
|
||||||
|
validity check that ``merge_will`` runs after merging.
|
||||||
|
"""
|
||||||
|
class FakeWallet:
|
||||||
|
def add_input_info(self, txin, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_output_info(self, txout, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_tx_info(self, tx):
|
||||||
|
return SimpleNamespace(
|
||||||
|
tx_mined_status=SimpleNamespace(height=lambda: 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
return SimpleNamespace(get_transaction=lambda txid: None)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
fake = SimpleNamespace(
|
||||||
|
willitems=willitems,
|
||||||
|
will={},
|
||||||
|
wallet=FakeWallet(),
|
||||||
|
bal_window=None,
|
||||||
|
date_to_check=1700000000,
|
||||||
|
bal_plugin=SimpleNamespace(
|
||||||
|
HISTORY_LABEL=SimpleNamespace(
|
||||||
|
get=lambda: "BAL will history ({willexecutor})"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
update_all=lambda: calls.append("update_all"),
|
||||||
|
)
|
||||||
|
fake.save_willitems = MethodType(window_mod.BalWindow.save_willitems, fake)
|
||||||
|
return fake, calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_will_same_id_unsigned_live_substitutes_signed_imported():
|
||||||
|
# The realistic "signed on another machine, imported here" case: the live
|
||||||
|
# will holds an unsigned PSBT (txid() -> None), the imported one is signed
|
||||||
|
# (complete). The transaction must be substituted and COMPLETE set.
|
||||||
|
wid = "same_id"
|
||||||
|
live_tx = _make_partial_tx(locktime=1000, signed=False)
|
||||||
|
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
||||||
|
|
||||||
|
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
||||||
|
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
||||||
|
imported[wid].set_status("COMPLETE", True)
|
||||||
|
imported[wid].set_status("PUSHED", True)
|
||||||
|
imported[wid].set_status("CHECKED", True)
|
||||||
|
|
||||||
|
fake, calls = _make_merge_fake(live)
|
||||||
|
live_item = live[wid]
|
||||||
|
|
||||||
|
window_mod.BalWindow.merge_will(fake, imported)
|
||||||
|
|
||||||
|
# The live WillItem object is kept (never replaced) and the signed tx
|
||||||
|
# substituted in.
|
||||||
|
assert live[wid] is live_item
|
||||||
|
assert live_item.tx is imported_tx
|
||||||
|
assert live_item.tx.is_complete()
|
||||||
|
assert live_item.get_status("COMPLETE") is True
|
||||||
|
# Operational statuses were carried over.
|
||||||
|
assert live_item.get_status("PUSHED") is True
|
||||||
|
assert live_item.get_status("CHECKED") is True
|
||||||
|
# The will was saved and the GUI refreshed.
|
||||||
|
assert wid in fake.will
|
||||||
|
assert fake.will[wid]["COMPLETE"] is True
|
||||||
|
assert "update_all" in calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_will_same_id_both_signed_combines():
|
||||||
|
# Live tx is signed but the COMPLETE flag was not set yet; the imported
|
||||||
|
# signed tx with the same txid must be COMBINED into the live one (the live
|
||||||
|
# tx object is kept) rather than substituted.
|
||||||
|
from electrum.transaction import Transaction
|
||||||
|
|
||||||
|
wid = "same_id_combine"
|
||||||
|
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
||||||
|
txid = imported_tx.txid()
|
||||||
|
assert txid is not None
|
||||||
|
|
||||||
|
live_tx = _make_partial_tx(locktime=1000, signed=True)
|
||||||
|
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
||||||
|
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
||||||
|
imported[wid].set_status("COMPLETE", True)
|
||||||
|
|
||||||
|
fake, _ = _make_merge_fake(live)
|
||||||
|
live_item = live[wid]
|
||||||
|
|
||||||
|
window_mod.BalWindow.merge_will(fake, imported)
|
||||||
|
|
||||||
|
# Same txid -> combine_with_other_psbt: live tx object is preserved.
|
||||||
|
assert live_item.tx is live_tx
|
||||||
|
assert live_item.tx.is_complete()
|
||||||
|
assert live_item.get_status("COMPLETE") is True
|
||||||
|
raw_sig = Transaction(_VALID_TX_HEX).inputs()[0].script_sig
|
||||||
|
assert live_item.tx.inputs()[0].script_sig == raw_sig
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_will_same_id_already_complete_never_touches_live_tx():
|
||||||
|
wid = "already_complete"
|
||||||
|
live_tx = _make_partial_tx(locktime=1000, signed=False)
|
||||||
|
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
||||||
|
live[wid].set_status("COMPLETE", True)
|
||||||
|
|
||||||
|
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
||||||
|
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
||||||
|
imported[wid].set_status("COMPLETE", True)
|
||||||
|
imported[wid].set_status("PUSHED", True)
|
||||||
|
|
||||||
|
fake, _ = _make_merge_fake(live)
|
||||||
|
live_item = live[wid]
|
||||||
|
|
||||||
|
window_mod.BalWindow.merge_will(fake, imported)
|
||||||
|
|
||||||
|
# An already-signed live will is left untouched: no combine, no substitute.
|
||||||
|
assert live_item.tx is live_tx
|
||||||
|
assert live_item.tx.is_complete() is False
|
||||||
|
assert live_item.tx.inputs()[0].script_sig is None
|
||||||
|
assert live_item.get_status("COMPLETE") is True
|
||||||
|
assert live_item.get_status("PUSHED") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_will_statuses_are_monotonic():
|
||||||
|
# Statuses that are True in the live item must never be cleared by a False
|
||||||
|
# value coming from the imported item.
|
||||||
|
wid = "monotonic"
|
||||||
|
live_tx = _make_partial_tx(locktime=1000, signed=True)
|
||||||
|
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
||||||
|
live[wid].set_status("CONFIRMED", True)
|
||||||
|
|
||||||
|
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
||||||
|
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
||||||
|
imported[wid].set_status("MEMPOOL", True)
|
||||||
|
|
||||||
|
fake, _ = _make_merge_fake(live)
|
||||||
|
live_item = live[wid]
|
||||||
|
|
||||||
|
window_mod.BalWindow.merge_will(fake, imported)
|
||||||
|
|
||||||
|
assert live_item.get_status("CONFIRMED") is True
|
||||||
|
assert live_item.get_status("MEMPOOL") is True
|
||||||
|
assert live_item.get_status("COMPLETE") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_will_new_ids_added_wholesale():
|
||||||
|
live_tx = _make_partial_tx(locktime=1000, signed=True)
|
||||||
|
live = {live_tx.txid(): _make_willitem_with_tx(live_tx)}
|
||||||
|
|
||||||
|
imported_tx = _make_partial_tx(locktime=2000, signed=True)
|
||||||
|
new_id = imported_tx.txid()
|
||||||
|
imported = {new_id: _make_willitem_with_tx(imported_tx)}
|
||||||
|
imported[new_id].set_status("PUSHED", True)
|
||||||
|
|
||||||
|
fake, _ = _make_merge_fake(live)
|
||||||
|
|
||||||
|
window_mod.BalWindow.merge_will(fake, imported)
|
||||||
|
|
||||||
|
assert len(live) == 2
|
||||||
|
assert live[new_id] is imported[new_id]
|
||||||
|
assert live[new_id].get_status("PUSHED") is True
|
||||||
|
assert new_id in fake.will
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_will_from_file_invalid_file_raises():
|
||||||
|
from bal.gui.qt.common import FileImportFailed
|
||||||
|
|
||||||
|
def bad_load(path):
|
||||||
|
raise ValueError("bad file")
|
||||||
|
|
||||||
|
fake = SimpleNamespace(_load_will_file=bad_load, wallet=None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
window_mod.BalWindow.merge_will_from_file(fake, "/nonexistent.json")
|
||||||
|
except FileImportFailed as e:
|
||||||
|
assert "bad file" in str(e)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected FileImportFailed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_will_partial_signatures_update_counts():
|
||||||
|
# A live unsigned 2-of-3 multisig will merged with an imported copy that
|
||||||
|
# carries 1 signature must end up PARTIALLY_SIGNED with the sig counts
|
||||||
|
# refreshed on the live item (check_signatures runs after the merge).
|
||||||
|
from binascii import unhexlify
|
||||||
|
|
||||||
|
from electrum import crypto
|
||||||
|
from electrum.descriptor import parse_descriptor
|
||||||
|
from electrum.transaction import (
|
||||||
|
PartialTransaction,
|
||||||
|
PartialTxInput,
|
||||||
|
PartialTxOutput,
|
||||||
|
Sighash,
|
||||||
|
TxOutpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_multisig_tx(nsigs):
|
||||||
|
pubs = []
|
||||||
|
for seed in (1, 2, 3):
|
||||||
|
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
|
||||||
|
desc = parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs)))
|
||||||
|
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
|
||||||
|
txin.script_descriptor = desc
|
||||||
|
txin._trusted_value_sats = 100000
|
||||||
|
txin.sighash = Sighash.ALL
|
||||||
|
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
|
||||||
|
for i in range(nsigs):
|
||||||
|
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
|
||||||
|
from electrum.bitcoin import public_key_to_p2wpkh
|
||||||
|
|
||||||
|
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
|
||||||
|
ptx = PartialTransaction()
|
||||||
|
ptx.add_inputs([txin])
|
||||||
|
ptx.add_outputs([PartialTxOutput.from_address_and_value(addr, 50000)])
|
||||||
|
ptx.locktime = 1000
|
||||||
|
return ptx, desc
|
||||||
|
|
||||||
|
wid = "multisig_will"
|
||||||
|
live_tx, _ = make_multisig_tx(0)
|
||||||
|
imported_tx, _ = make_multisig_tx(1)
|
||||||
|
|
||||||
|
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
||||||
|
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
||||||
|
|
||||||
|
fake, _ = _make_merge_fake(live)
|
||||||
|
live_item = live[wid]
|
||||||
|
|
||||||
|
window_mod.BalWindow.merge_will(fake, imported)
|
||||||
|
|
||||||
|
assert live_item.get_status("PARTIALLY_SIGNED") is True
|
||||||
|
assert live_item.sigs_have == 1
|
||||||
|
assert live_item.sigs_required == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_detail_dialog_merge_switches_to_live():
|
||||||
|
from bal.gui.qt.dialogs import WillDetailDialog
|
||||||
|
|
||||||
|
window_widget = QWidget()
|
||||||
|
bal_window = _make_fake_bal_window(window_widget)
|
||||||
|
bal_window.window.config = SimpleNamespace()
|
||||||
|
bal_window.window.format_amount = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.base_unit = "BTC"
|
||||||
|
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.fx = None
|
||||||
|
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
|
||||||
|
bal_window.window.get_decimal_point = lambda: 8
|
||||||
|
|
||||||
|
live = bal_window.willitems
|
||||||
|
imported = _make_willitems(2)
|
||||||
|
merged = []
|
||||||
|
bal_window.merge_will = lambda will: merged.append(will)
|
||||||
|
|
||||||
|
dialog = WillDetailDialog(bal_window, will=imported)
|
||||||
|
|
||||||
|
assert dialog._external_will is True
|
||||||
|
assert dialog.merge_button is not None
|
||||||
|
assert dialog.merge_button.isHidden() is False
|
||||||
|
|
||||||
|
dialog.merge_will()
|
||||||
|
|
||||||
|
assert merged == [imported]
|
||||||
|
assert dialog._external_will is False
|
||||||
|
assert dialog.will is live
|
||||||
|
assert dialog.threshold == bal_window.will_settings["real_threshold"]
|
||||||
|
assert dialog.merge_button.isHidden() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All import-will-details tests passed")
|
||||||
@@ -33,10 +33,9 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from electrum import constants
|
from electrum import constants
|
||||||
|
|
||||||
constants.net = constants.BitcoinRegtest
|
constants.net = constants.BitcoinRegtest
|
||||||
@@ -48,10 +47,14 @@ from electrum.transaction import PartialTxInput, TxOutpoint
|
|||||||
from electrum.util import bfh
|
from electrum.util import bfh
|
||||||
|
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
from bal.core.will import Will, WillItem, NoWillExecutorNotPresent, NotCompleteWillException
|
from bal.core.will import (
|
||||||
|
NotCompleteWillException,
|
||||||
|
NoWillExecutorNotPresent,
|
||||||
|
Will,
|
||||||
|
WillItem,
|
||||||
|
)
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Load karen7 wallet data
|
# Load karen7 wallet data
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -479,11 +482,11 @@ class TestNoWillexecutorKaren7:
|
|||||||
dialog = FakeBuildWillDialog(self.bal_window)
|
dialog = FakeBuildWillDialog(self.bal_window)
|
||||||
dialog.task_phase1()
|
dialog.task_phase1()
|
||||||
assert any(
|
assert any(
|
||||||
"Not present - select one or enable backup mode" in l
|
"Not present - select one or enable backup mode" in label
|
||||||
for l in dialog.labels
|
for label in dialog.labels
|
||||||
), "dialog labels must contain the 'not present' message"
|
), "dialog labels must contain the 'not present' message"
|
||||||
assert any(
|
assert any(
|
||||||
"#ff0000" in l for l in dialog.labels
|
"#ff0000" in label for label in dialog.labels
|
||||||
), "dialog labels must use red (COLOR_ERROR)"
|
), "dialog labels must use red (COLOR_ERROR)"
|
||||||
|
|
||||||
def test_task_phase1_adds_action_buttons(self):
|
def test_task_phase1_adds_action_buttons(self):
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ if os.path.isdir(ELECTRUM_DIR):
|
|||||||
sys.path.insert(0, ELECTRUM_DIR)
|
sys.path.insert(0, ELECTRUM_DIR)
|
||||||
|
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
from bal.core.willexecutors import Willexecutors
|
|
||||||
from bal.core.will import Will, WillItem, NotCompleteWillException, NoHeirsException, NoWillExecutorNotPresent
|
|
||||||
from bal.core.plugin_base import BalPlugin, BalTimestamp
|
from bal.core.plugin_base import BalPlugin, BalTimestamp
|
||||||
|
from bal.core.will import (
|
||||||
|
NoHeirsException,
|
||||||
|
NotCompleteWillException,
|
||||||
|
NoWillExecutorNotPresent,
|
||||||
|
Will,
|
||||||
|
WillItem,
|
||||||
|
)
|
||||||
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Load karen7 wallet data
|
# Load karen7 wallet data
|
||||||
@@ -53,7 +59,7 @@ def build_utxos(data):
|
|||||||
for txid, outputs in txo.items():
|
for txid, outputs in txo.items():
|
||||||
if not isinstance(outputs, dict):
|
if not isinstance(outputs, dict):
|
||||||
continue
|
continue
|
||||||
for addr, out_map in outputs.items():
|
for _, out_map in outputs.items():
|
||||||
if not isinstance(out_map, dict):
|
if not isinstance(out_map, dict):
|
||||||
continue
|
continue
|
||||||
for idx, info in out_map.items():
|
for idx, info in out_map.items():
|
||||||
@@ -85,7 +91,6 @@ class FakeBalWindow:
|
|||||||
def init_class_variables(self):
|
def init_class_variables(self):
|
||||||
if not self.heirs:
|
if not self.heirs:
|
||||||
raise NoHeirsException("Heirs are not defined")
|
raise NoHeirsException("Heirs are not defined")
|
||||||
from bal.core.plugin_base import BalTimestamp
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
|
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
|
||||||
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
|
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
|
||||||
|
|||||||
136
tests/test_settings_history_dialog.py
Normal file
136
tests/test_settings_history_dialog.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Tests for the "Save inheritance transactions in history" settings dialog rows.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- The history label line-edit is present and bound to the HISTORY_LABEL
|
||||||
|
config (its text is the configured label).
|
||||||
|
- The rows are hidden in BASIC mode and visible in ADVANCED mode (advanced
|
||||||
|
-only settings, mirroring the other advanced rows).
|
||||||
|
- The history label line-edit is disabled while the "Save inheritance
|
||||||
|
transactions in history" checkbox is off, and re-enabled when it is on.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source /home/steal/devel/bal/electrum/env/bin/activate
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_settings_history_dialog.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
|
||||||
|
import bal.gui.qt.plugin as plugin_mod
|
||||||
|
from bal.gui.qt.plugin import Plugin
|
||||||
|
from bal.gui.qt.widgets import BalCheckBox, BalLineEdit
|
||||||
|
|
||||||
|
DEFAULT_LABEL = "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
|
||||||
|
|
||||||
|
def _isolated_config(**overrides):
|
||||||
|
"""An in-memory SimpleConfig that never touches the real Electrum config.
|
||||||
|
|
||||||
|
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
|
||||||
|
the tests cannot pollute the user's config files.
|
||||||
|
"""
|
||||||
|
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
|
||||||
|
opts.update(overrides)
|
||||||
|
return SimpleConfig(opts)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dialog(user_type, **config_overrides):
|
||||||
|
"""Build the plugin settings dialog for *user_type* and return it.
|
||||||
|
|
||||||
|
``settings_dialog`` ends with a blocking ``show_modal(d)`` call; we patch it
|
||||||
|
to capture the dialog and return immediately.
|
||||||
|
"""
|
||||||
|
cfg = _isolated_config(**config_overrides)
|
||||||
|
plugin = Plugin(None, cfg, "bal")
|
||||||
|
plugin.get_window_title = lambda s: s
|
||||||
|
plugin.read_file = lambda *a: b""
|
||||||
|
plugin.broadcast_transactions = lambda *a, **k: None
|
||||||
|
plugin.update_all = lambda *a, **k: None
|
||||||
|
plugin.USER_TYPE.set(user_type)
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def fake_show_modal(dlg):
|
||||||
|
captured.append(dlg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
with patch.object(plugin_mod, "show_modal", side_effect=fake_show_modal):
|
||||||
|
plugin.settings_dialog(None, None)
|
||||||
|
assert captured, "settings_dialog did not build a dialog"
|
||||||
|
return plugin, captured[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _history_label_edit(dialog):
|
||||||
|
edits = [
|
||||||
|
w for w in dialog.findChildren(BalLineEdit) if w.text() == DEFAULT_LABEL
|
||||||
|
]
|
||||||
|
assert len(edits) == 1, f"expected exactly one history label edit, got {len(edits)}"
|
||||||
|
return edits[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_label_row_hidden_in_basic_visible_in_advanced():
|
||||||
|
plugin, basic = _build_dialog("basic")
|
||||||
|
assert _history_label_edit(basic).isHidden() is True
|
||||||
|
basic.close()
|
||||||
|
|
||||||
|
plugin, advanced = _build_dialog("advanced")
|
||||||
|
edit = _history_label_edit(advanced)
|
||||||
|
assert edit.isHidden() is False
|
||||||
|
advanced.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_label_field_follows_checkbox():
|
||||||
|
# Default: SAVE_HISTORY is ON, so the label field starts enabled.
|
||||||
|
plugin, dialog = _build_dialog("advanced")
|
||||||
|
edit = _history_label_edit(dialog)
|
||||||
|
assert edit.isEnabled() is True
|
||||||
|
|
||||||
|
# Find the checkbox that controls the label field's enabled state: it must
|
||||||
|
# be the "Save inheritance transactions in history" checkbox (the only one
|
||||||
|
# whose off-state disables the label field).
|
||||||
|
toggler = None
|
||||||
|
for box in dialog.findChildren(BalCheckBox):
|
||||||
|
if not box.isChecked():
|
||||||
|
continue
|
||||||
|
box.setChecked(False)
|
||||||
|
if not edit.isEnabled():
|
||||||
|
toggler = box
|
||||||
|
break
|
||||||
|
assert toggler is not None, "no checkbox disables the history label field"
|
||||||
|
|
||||||
|
# Toggling it back on re-enables the field.
|
||||||
|
toggler.setChecked(True)
|
||||||
|
assert edit.isEnabled() is True
|
||||||
|
assert plugin.SAVE_HISTORY.get() is True
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_label_field_disabled_from_start_when_off():
|
||||||
|
plugin, dialog = _build_dialog(
|
||||||
|
"advanced", **{"bal_save_history": False}
|
||||||
|
)
|
||||||
|
assert _history_label_edit(dialog).isEnabled() is False
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All settings-history dialog tests passed")
|
||||||
@@ -45,10 +45,10 @@ class _WindowsLikeDatetime(_real_datetime):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
plugin_base = importlib.import_module(f"{PKG}.core.plugin_base")
|
plugin_base = importlib.import_module(f"{PKG}.core.plugin_base")
|
||||||
BalTimestamp = plugin_base.BalTimestamp
|
bt_class = plugin_base.BalTimestamp
|
||||||
|
|
||||||
# 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already.
|
# 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already.
|
||||||
bt = BalTimestamp(NLOCKTIME_MAX)
|
bt = bt_class(NLOCKTIME_MAX)
|
||||||
d = bt.to_date()
|
d = bt.to_date()
|
||||||
assert isinstance(d, _real_datetime), d
|
assert isinstance(d, _real_datetime), d
|
||||||
print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform")
|
print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform")
|
||||||
@@ -59,7 +59,7 @@ def main():
|
|||||||
plugin_base.datetime = _WindowsLikeDatetime
|
plugin_base.datetime = _WindowsLikeDatetime
|
||||||
try:
|
try:
|
||||||
# 2a) Absolute sentinel timestamp (the exact crash path from the log).
|
# 2a) Absolute sentinel timestamp (the exact crash path from the log).
|
||||||
bt = BalTimestamp(NLOCKTIME_MAX)
|
bt = bt_class(NLOCKTIME_MAX)
|
||||||
d = bt.to_date() # must NOT raise OverflowError anymore
|
d = bt.to_date() # must NOT raise OverflowError anymore
|
||||||
assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}"
|
assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}"
|
||||||
print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)")
|
print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)")
|
||||||
@@ -75,13 +75,13 @@ def main():
|
|||||||
print("[OK] str()/repr() on out-of-range timestamp are safe")
|
print("[OK] str()/repr() on out-of-range timestamp are safe")
|
||||||
|
|
||||||
# 2d) Relative durations that overflow when added (e.g. huge 'd').
|
# 2d) Relative durations that overflow when added (e.g. huge 'd').
|
||||||
bt_rel = BalTimestamp(f"{10 ** 9}d") # ~2.7M years -> overflow
|
bt_rel = bt_class(f"{10 ** 9}d") # ~2.7M years -> overflow
|
||||||
d2 = bt_rel.to_date()
|
d2 = bt_rel.to_date()
|
||||||
assert d2 is not None
|
assert d2 is not None
|
||||||
print("[OK] huge relative duration no longer raises")
|
print("[OK] huge relative duration no longer raises")
|
||||||
|
|
||||||
# 2e) Normal values are unchanged (behaviour-preserving check).
|
# 2e) Normal values are unchanged (behaviour-preserving check).
|
||||||
bt_norm = BalTimestamp("90d")
|
bt_norm = bt_class("90d")
|
||||||
d3 = bt_norm.to_date()
|
d3 = bt_norm.to_date()
|
||||||
# 90 days from now, normalised to midnight
|
# 90 days from now, normalised to midnight
|
||||||
assert d3.hour == 0 and d3.minute == 0 and d3.second == 0
|
assert d3.hour == 0 and d3.minute == 0 and d3.second == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user