add tests

This commit is contained in:
bot
2026-06-20 09:49:39 -04:00
parent 86ed0297a7
commit 525dde2b3c
34 changed files with 7427 additions and 0 deletions

285
tests/test_multiverse.py Normal file
View File

@@ -0,0 +1,285 @@
"""
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")