tests: toyserver: add basic remove_tx/reorg functionality
What changed, and why it matters
This commit only adds test infrastructure. It introduces an ordered set helper class and expands a fake Bitcoin server used in unit tests so it can remove transactions from the mempool and simulate blockchain reorganizations. There is no change to production wallet code, networking, or cryptography, and nothing in the commit suggests a security fix or vulnerability.
No security action needed. Treat as normal test-code maintenance.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff adds an OrderedSet implementation to electrum/util.py and uses it in tests/toyserver.py to track block txids in insertion order. It also adds _remove_tx, mempool_rm_tx, unmine_block, and refactors mine_block to optionally include mempool transactions. test_interface.py is updated to match the new mine_block signature. All changes are confined to test helpers and a generic utility class.
Changed components
electrum/util.py (new OrderedSet utility class)tests/toyserver.py (test fake Electrum server)tests/test_interface.py (test case update)Inspect captured patch +109 / −10
diff --git a/electrum/util.py b/electrum/util.py
index 1bf6025..33a90b2 100644
--- a/electrum/util.py
+++ b/electrum/util.py
@@ -29,6 +29,7 @@ import sys
import re
from collections import defaultdict, OrderedDict
from concurrent.futures.process import ProcessPoolExecutor
+import typing
from typing import (
NamedTuple, Union, TYPE_CHECKING, Tuple, Optional, Callable, Any, Sequence, Dict, Generic, TypeVar, List, Iterable,
Set, Awaitable
@@ -1854,6 +1855,36 @@ class OrderedDictWithIndex(OrderedDict):
return ret
+T = typing.TypeVar("T")
+
+class OrderedSet(typing.MutableSet[T]):
+ """A set that preserves insertion order by internally using a dict."""
+
+ def __init__(self, iterable: typing.Iterable[T] = ()):
+ self._d = dict.fromkeys(iterable)
+
+ def add(self, value: T) -> None:
+ self._d[value] = None
+
+ def discard(self, value: T) -> None:
+ self._d.pop(value, None)
+
+ def __contains__(self, value: object) -> bool:
+ return self._d.__contains__(value)
+
+ def __len__(self) -> int:
+ return self._d.__len__()
+
+ def __iter__(self) -> typing.Iterator[T]:
+ return self._d.__iter__()
+
+ def __str__(self):
+ return f"{{{', '.join(str(i) for i in self)}}}"
+
+ def __repr__(self):
+ return f"<OrderedSet {self}>"
+
+
def make_object_immutable(obj):
"""Makes the passed object immutable recursively."""
allowed_types = (
diff --git a/tests/test_interface.py b/tests/test_interface.py
index b730bf7..b846393 100644
--- a/tests/test_interface.py
+++ b/tests/test_interface.py
@@ -191,7 +191,7 @@ class TestInterface(ElectrumTestCase):
w1.adb.get_address_history(w1_addr),
{funding_txid: 0})
# mine funding tx
- await self._toyserver.mine_block(txs=[funding_tx])
+ await self._toyserver.mine_block()
server_blockheight += 1
await w1.up_to_date_changed_event.wait()
while not w1.is_up_to_date():
diff --git a/tests/toyserver.py b/tests/toyserver.py
index c8073a3..8d21630 100644
--- a/tests/toyserver.py
+++ b/tests/toyserver.py
@@ -12,7 +12,7 @@ import aiorpcx
from aiorpcx import RPCError
from electrum import blockchain
-from electrum.util import bfh
+from electrum.util import bfh, OrderedSet
from electrum.logging import Logger
from electrum.transaction import Transaction, TxOutput, TxInput, TxOutpoint, PartialTxOutput
from electrum import constants
@@ -34,7 +34,11 @@ REGTEST_GENESIS_HEADER = bfh("01000000000000000000000000000000000000000000000000
@dataclass(kw_only=True, slots=True, frozen=True)
class FakeBlock:
header: bytes
- txids: Sequence[str] = ()
+ txids: OrderedSet[str] = None
+
+ def __post_init__(self):
+ if self.txids is None:
+ object.__setattr__(self, 'txids', OrderedSet())
class ToyServer:
@@ -50,7 +54,7 @@ class ToyServer:
# indexes:
self.sh_to_funding_txids = collections.defaultdict(set) # type: dict[str, set[str]]
self.sh_to_spending_txids = collections.defaultdict(set) # type: dict[str, set[str]]
- self.txs = {} # type: dict[str, bytes]
+ self.txs = {} # type: dict[str, bytes] # txid->raw_tx
self._cache_blockheight_from_txid = {} # type: dict[str, int]
self.txo_to_spender_txid = {} # type: dict[TxOutpoint, str | None] # also contains UTXOs
@@ -81,6 +85,13 @@ class ToyServer:
return height
return None
+ def get_mempool_txids(self) -> set[str]: # FIXME slow
+ mempool = set()
+ for txid in self.txs:
+ if self.block_height_from_txid(txid) is None:
+ mempool.add(txid)
+ return mempool
+
def get_session_by_name(self, client_name: str) -> 'ToyServerSession':
found_sessions = [
session for session in self.sessions
@@ -158,24 +169,64 @@ class ToyServer:
self.sh_to_spending_txids[sh].add(txid)
return funded_sh | spent_sh
+ def _remove_tx(self, tx: Transaction) -> set[str]:
+ txid = tx.txid()
+ assert txid
+ self.txs.pop(txid)
+ # un-fund UTXOs
+ for txout_idx, txout in enumerate(tx.outputs()):
+ outpoint = TxOutpoint(txid=bfh(txid), out_idx=txout_idx)
+ assert self.txo_to_spender_txid[outpoint] is None, "output already spent"
+ self.txo_to_spender_txid.pop(outpoint)
+ # un-spend UTXOs
+ for txin in tx.inputs():
+ if txin.is_coinbase_input():
+ continue
+ assert self.txo_to_spender_txid[txin.prevout] == txid
+ self.txo_to_spender_txid[txin.prevout] = None
+ # update touched scripthashes
+ funded_sh, spent_sh = self._get_funded_and_spent_scripthashes(tx)
+ for sh in funded_sh:
+ self.sh_to_funding_txids[sh].discard(txid)
+ for sh in spent_sh:
+ self.sh_to_spending_txids[sh].discard(txid)
+ return funded_sh | spent_sh
+
async def mempool_add_tx(self, tx: Transaction) -> None:
touched_sh = self._add_tx(tx)
# notify clients
for session in self.sessions:
await session.server_send_notifications(touched_sh=touched_sh)
+ async def mempool_rm_tx(self, tx: Transaction) -> None:
+ txid = tx.txid()
+ assert txid
+ assert txid in self.txs, "unknown tx"
+ assert self.block_height_from_txid(txid) is None, "tx already mined"
+ touched_sh = self._remove_tx(tx)
+ # notify clients
+ for session in self.sessions:
+ await session.server_send_notifications(touched_sh=touched_sh)
+
async def mine_block(
self,
*,
- txs: Iterable[Transaction] = None,
- coinbase_outputs: Iterable[TxOutput] = None, # hmhm maturity?
+ coinbase_outputs: Iterable[TxOutput] = None,
+ include_mempool: bool = True, # whether to mine (all) txs in the mempool
+ extra_txs: Iterable[Transaction] = None, # additional txs to mine. can overlap with mempool
) -> tuple[FakeBlock, Transaction]:
- if txs is None:
- txs = []
+ if extra_txs is None:
+ extra_txs = []
coinbase_tx = Transaction(None)
coinbase_tx._inputs = [TxInput(prevout=TxOutpoint(txid=bytes(32), out_idx=0xffffffff))]
coinbase_tx._outputs = list(coinbase_outputs or []) + [TxOutput(scriptpubkey=bfh("6a04deadbeef"), value=0)]
- txs = [coinbase_tx] + txs
+ txs = OrderedSet() # type: OrderedSet[Transaction]
+ txs.add(coinbase_tx)
+ txs |= OrderedSet(extra_txs)
+ if include_mempool:
+ for mempool_txid in self.get_mempool_txids():
+ mempool_tx = Transaction(self.txs[mempool_txid])
+ txs.add(mempool_tx)
assert not any(tx.txid() is None for tx in txs)
# new header
prev_header = self._blocks[-1].header
@@ -188,7 +239,7 @@ class ToyServer:
'bits': 0x1d00ffff, # don't care
'nonce': 1, # don't care
})
- new_block = FakeBlock(header=new_header, txids=tuple(tx.txid() for tx in txs))
+ new_block = FakeBlock(header=new_header, txids=OrderedSet(tx.txid() for tx in txs))
self._blocks.append(new_block)
# process txs
touched_sh = set()
@@ -199,6 +250,23 @@ class ToyServer:
await session.server_send_notifications(touched_sh=touched_sh, height_changed=True)
return new_block, coinbase_tx
+ async def unmine_block(self) -> None:
+ if self.cur_height == 0:
+ raise Exception("cannot unmine genesis")
+ # Simply pop the block from the chain.
+ block = self._blocks.pop()
+ # process txs
+ # note: all txs in that block are now automatically considered to be in-mempool.
+ # no need to call _remove_tx -- that would also rm them from the mempool.
+ touched_sh = set()
+ for txid in block.txids:
+ tx = Transaction(self.txs[txid])
+ funded_sh, spent_sh = self._get_funded_and_spent_scripthashes(tx)
+ touched_sh |= funded_sh | spent_sh
+ # notify clients
+ for session in self.sessions:
+ await session.server_send_notifications(touched_sh=touched_sh, height_changed=True)
+
async def set_up_faucet(self, *, config: SimpleConfig):
assert self._faucet_w is None
self._faucet_w = restore_wallet_from_text__for_unittest(
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.