forked from bitcoinafterlife/bal-electrum-plugin
add tests
This commit is contained in:
285
tests/test_multiverse.py
Normal file
285
tests/test_multiverse.py
Normal 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")
|
||||
Reference in New Issue
Block a user