Files
bal-electrum-plugin/tests/test_multiverse.py
2026-06-20 09:49:39 -04:00

286 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for multiverse feature: multiple locktimes among heirs.
Run:
source electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_multiverse.py
"""
import sys
import os
import time
import copy
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
Heirs, prepare_transactions,
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
)
from bal.core.util import Util
from bal.core.plugin_base import BalConfig
from bal.core.will import WillItem, Will
from electrum.transaction import (
PartialTransaction, PartialTxInput, TxOutpoint,
)
from electrum.util import bfh
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def fake_wallet(change_addr="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"):
w = MagicMock()
w.dust_threshold.return_value = 500
change_mock = MagicMock()
change_mock.__getitem__.return_value = change_addr
w.get_change_addresses_for_new_transaction.return_value = change_mock
return w
MAINNET_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
def make_heir(name, addr, amount, locktime):
return {name: [addr, amount, locktime]}
# ------------------------------------------------------------------ #
# parse_locktime_string various formats
# ------------------------------------------------------------------ #
def test_parse_d_suffix_is_timestamp():
result = Util.parse_locktime_string("30d")
assert result > 500000000
def test_parse_y_suffix_is_timestamp():
result = Util.parse_locktime_string("1y")
assert result > 500000000
def test_parse_plain_int_preserved():
ts = int(time.time()) + 86400
assert Util.parse_locktime_string(str(ts)) == ts
# ------------------------------------------------------------------ #
# get_lowest_locktimes returns sorted list
# ------------------------------------------------------------------ #
def test_get_lowest_locktimes_returns_sorted_list():
result = Util.get_lowest_locktimes(["30d", "90d", "1y"])
assert isinstance(result, list)
assert len(result) == 3
for lt in result:
assert lt > 500000000
assert result == sorted(result)
def test_get_lowest_locktimes_empty():
assert Util.get_lowest_locktimes([]) == []
def test_get_lowest_locktimes_min():
now = int(time.time())
locktimes_list = [now + 86400, now, now + 2 * 86400]
result = Util.get_lowest_locktimes(locktimes_list)
assert result[0] == now
assert result == sorted(result)
# ------------------------------------------------------------------ #
# prepare_lists with multiple locktimes (multiverse)
# ------------------------------------------------------------------ #
def test_prepare_lists_single_locktime():
"""All heirs share the same locktime -> single group."""
wallet = fake_wallet()
h = Heirs.__new__(Heirs)
h.update(make_heir("a", MAINNET_ADDR, 5000, "30d"))
h.update(make_heir("b", MAINNET_ADDR, 3000, "30d"))
result, onlyfixed = h.prepare_lists(100000, 100, wallet)
assert len(result) == 1
locktime = list(result.keys())[0]
assert locktime > 500000000
assert len(result[locktime]) == 2
def test_prepare_lists_multiple_locktimes():
"""Heirs with different locktimes -> multiple groups."""
wallet = fake_wallet()
h = Heirs.__new__(Heirs)
far_future_1 = int(time.time()) + 365 * 86400
far_future_2 = int(time.time()) + 2 * 365 * 86400
h.update(make_heir("a", MAINNET_ADDR, 5000, str(far_future_1)))
h.update(make_heir("b", MAINNET_ADDR, 3000, str(far_future_2)))
result, onlyfixed = h.prepare_lists(100000, 100, wallet)
assert len(result) == 2
locktimes = sorted(result.keys())
assert locktimes[0] == far_future_1
assert locktimes[1] == far_future_2
def test_prepare_lists_mixed_locktimes():
"""Two heirs share a locktime, one has different -> 2 groups."""
wallet = fake_wallet()
h = Heirs.__new__(Heirs)
far_future_1 = int(time.time()) + 365 * 86400
far_future_2 = int(time.time()) + 730 * 86400
h.update(make_heir("a", MAINNET_ADDR, 5000, str(far_future_1)))
h.update(make_heir("b", MAINNET_ADDR, 3000, str(far_future_1)))
h.update(make_heir("c", MAINNET_ADDR, 2000, str(far_future_2)))
result, onlyfixed = h.prepare_lists(100000, 100, wallet)
assert len(result) == 2
two_heir_group = [g for g in result.values() if len(g) == 2]
assert len(two_heir_group) == 1
one_heir_group = [g for g in result.values() if len(g) == 1]
assert len(one_heir_group) == 1
# ------------------------------------------------------------------ #
# get_locktimes from different locktime styles
# ------------------------------------------------------------------ #
def test_get_locktimes_various():
"""Mix of relative and absolute locktimes produces sorted timestamps."""
now = int(time.time())
tomorrow = now + 86400
day_after = now + 2 * 86400
h = Heirs.__new__(Heirs)
h.update(make_heir("a", "addr1", 5000, "1d"))
h.update(make_heir("b", "addr2", 3000, str(day_after)))
h.update(make_heir("c", "addr3", 2000, str(tomorrow)))
locktimes = h.get_locktimes(0)
assert len(locktimes) == 3
for lt in locktimes:
assert lt > 500000000
# ------------------------------------------------------------------ #
# Locktime propagation: tx.nLockTime matches lowest heir locktime
# ------------------------------------------------------------------ #
def make_utxo(txid="deadbeef" * 8, vout=0, value=1_000_000):
"""Build a ``PartialTxInput`` with the minimum fields required by the
transaction builder. ``prepare_transactions`` calls
``tx.remove_signatures()``, so we patch it out here."""
prevout = TxOutpoint(txid=bfh(txid), out_idx=vout)
txin = PartialTxInput(prevout=prevout)
txin._trusted_value_sats = value
return txin
def test_transaction_locktime_matches_lowest_heir():
"""The built tx's nLockTime equals the resolved lowest heir locktime.
With multiple locktime groups only the lowest is processed."""
lt_low = int(time.time()) + 86400
lt_high = int(time.time()) + 365 * 86400
locktimes = {
lt_high: {"b": [MAINNET_ADDR, 10000, str(lt_high)]},
lt_low: {"a": [MAINNET_ADDR, 10000, str(lt_low)]},
}
utxos = [make_utxo("aa" * 32, 0, 500_000)]
fees = {lt_low: 100, lt_high: 100}
with patch("bal.core.heirs.PartialTransaction.remove_signatures"), \
patch("bal.core.heirs.PartialTransaction.txid",
side_effect=["10" * 32, "10" * 32]):
wallet = fake_wallet()
result = prepare_transactions(locktimes, utxos, fees, wallet)
assert len(result) == 1, f"expected 1 tx (lowest locktime), got {len(result)}"
tx = list(result.values())[0]
assert tx.locktime == lt_low
# ------------------------------------------------------------------ #
# Anticipate: build txs, modify heirs, rebuild, verify replacement
# ------------------------------------------------------------------ #
def _make_willitem(tx, txid):
"""Minimal ``WillItem`` without serialization round-trip."""
wi = WillItem.__new__(WillItem)
wi.tx = tx
wi._id = txid
wi.heirs = getattr(tx, "heirs", None)
wi.we = getattr(tx, "willexecutor", None)
wi.status = ""
wi.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
return wi
def test_anticipate_single_heir_locktime_reduction():
"""Old tx is REPLACED when the heir's locktime moves earlier."""
now = int(time.time())
lt_old = now + 10 * 86400
lt_new = now + 3 * 86400
h = Heirs.__new__(Heirs)
h.update(make_heir("a", MAINNET_ADDR, 10000, str(lt_old)))
locktimes_old, _ = h.prepare_lists(100000, 100, fake_wallet())
utxos = [make_utxo("10" * 32, 0, 500_000)]
fees_old = {lt_old: 100}
with patch("bal.core.heirs.PartialTransaction.remove_signatures"), \
patch("bal.core.heirs.PartialTransaction.txid",
side_effect=["66" * 32, "66" * 32]):
txs_old = prepare_transactions(locktimes_old, utxos[:], fees_old, fake_wallet())
old_txid = list(txs_old.keys())[0]
old_wi = _make_willitem(list(txs_old.values())[0], old_txid)
old_wi.set_status("VALID", True)
h2 = Heirs.__new__(Heirs)
h2.update(make_heir("a", MAINNET_ADDR, 10000, str(lt_new)))
locktimes_new, _ = h2.prepare_lists(100000, 100, fake_wallet())
fees_new = {lt_new: 100}
with patch("bal.core.heirs.PartialTransaction.remove_signatures"), \
patch("bal.core.heirs.PartialTransaction.txid",
side_effect=["88" * 32, "88" * 32]):
txs_new = prepare_transactions(locktimes_new, utxos[:], fees_new, fake_wallet())
new_txid = list(txs_new.keys())[0]
new_wi = _make_willitem(list(txs_new.values())[0], new_txid)
new_wi.set_status("VALID", True)
will = {old_txid: old_wi, new_txid: new_wi}
all_inputs = Will.get_all_inputs(will)
Will.search_rai(all_inputs, utxos, will, None)
assert old_wi.get_status("REPLACED"), \
"old tx should be REPLACED (new locktime is earlier)"
assert not old_wi.get_status("VALID"), \
"old tx should no longer be VALID"
assert new_wi.get_status("VALID"), \
"new tx should remain VALID"
assert not new_wi.get_status("REPLACED"), \
"new tx should not be REPLACED"
# ------------------------------------------------------------------ #
# Multiverse default (plugin_base)
# ------------------------------------------------------------------ #
def test_multiverse_default_true():
"""ENABLE_MULTIVERSE should default to True."""
config = MagicMock()
config.get.side_effect = lambda key, default: default
mv = BalConfig(config, "bal_enable_multiverse", True)
assert mv.get() is True
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
obj()
print(f" [OK] {name}")
print("[OK] All multiverse tests passed")