rostrum 14.0.1

An efficient implementation of Electrum Server with token support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
#!/usr/bin/env python3
# Copyright (c) 2019 The Bitcoin Unlimited developers
"""
Tests to check if basic electrum server integration works
"""

import asyncio
from test_framework.util import assert_equal, wait_for
from test_framework.electrumutil import (
    ElectrumTestFramework,
    script_to_scripthash,
)

# node, node_supports, NodeFeature unused - commented out token test
# from test_framework.environment import node, node_supports, NodeFeature
from test_framework.electrumconnection import ElectrumConnection
from test_framework.constants import COIN
from test_framework.script import CScript, OP_TRUE, OP_DROP
from test_framework.utilcompat import lock, unlock
from test_framework.utiltx import pad_tx
from test_framework.serialize import to_hex
from test_framework.environment import on_bch, on_nex

if on_nex():
    from test_framework.nex.script import anyonecanspend_unlocking_script
elif on_bch():
    from test_framework.bch.script import anyonecanspend_unlocking_script
else:
    raise NotImplementedError()

DEFAULT_WAIT = 30


def confirmed_count(history):
    # token.* adds the history list under 'history' key,
    # while address.* responses do not.
    if "history" in history:
        history = history["history"]

    return sum(map(lambda i: i["height"] > 0, history))


class ElectrumReorgTests(ElectrumTestFramework):
    async def run_test(self):
        n = self.nodes[0]

        await self.bootstrap_p2p()
        cli = ElectrumConnection()
        await cli.connect()
        try:
            # Mine blocks using P2P at the beginning to get coinbases for deterministic transactions
            coinbases = await self.mine_blocks(cli, n, 100)
            # for spending wallet in node
            n.generate(100)
            # to mature the spending wallet
            n.generate(110)
            await self.test_reorg(cli, n)
            await self.test_reorg_unspent_index_recreation(cli, n, coinbases)
            await self.test_reorg_scripthash_history(cli, n, coinbases)
        finally:
            cli.disconnect()

    async def test_reorg(self, cli, n):
        address = n.getnewaddress()

        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

        # TODO: Token test is broken since it might re-use the funds from 'address' causing an extra tx
        test_tokens = False  # node_supports(node(), NodeFeature.TOKENS)

        # 1 coin transaction
        n.sendtoaddress(address, 10)
        await self.wait_for_mempool_count(cli, count=1)

        if test_tokens:
            # 2 token transactions
            token_id, _ = await self.create_token(
                to_addr=n.getnewaddress(), mint_amount=42
            )
            await self.wait_for_mempool_count(cli, count=3)
        await self.sync_mempool(cli)

        blocks = n.generate(50)

        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

        # Check that our transactions got indexed.
        assert (
            confirmed_count(await cli.call("blockchain.address.get_history", address))
            >= 1
        )
        if test_tokens:
            assert_equal(
                2,
                confirmed_count(
                    await cli.call("token.transaction.get_history", token_id)
                ),
            )

        self.info("invalidating %d blocks", len(blocks))
        n.invalidateblock(blocks[0])
        # Clear cached block hashes after reorg to avoid using stale ancestor hashes
        self.hash_at_height.clear()

        # electrum server should trim its chain as well and maybe see our
        # transactions go back into mempool (depending on maturity of inputs)
        await self.sync_all(cli)

        # the transactions should no longer exist on-chain
        async def no_confirmed_txs():
            return 0 == confirmed_count(
                await cli.call("blockchain.address.get_history", address)
            )

        await wait_for(
            10,
            no_confirmed_txs,
            f"got confirmed count {confirmed_count(await cli.call("blockchain.address.get_history", address))}, expecting 0",
        )

        if test_tokens:
            assert_equal(
                0,
                confirmed_count(
                    await cli.call("token.transaction.get_history", token_id)
                ),
            )

        n.generate(50)
        await self.sync_height(cli)

    async def test_reorg_unspent_index_recreation(  # pylint: disable=too-many-locals,too-many-statements
        self, cli, n, coinbases
    ):
        """
        Test that unspent index entries are correctly recreated for outputs
        that were created in earlier blocks but spent in the block being undone.

        This test verifies the fix for the bug where outputs created in block N
        and spent in block N+1 were not being re-added to the unspent index
        when block N+1 was undone during a reorg.
        """
        coinbase = coinbases.pop(0)

        # Use deterministic scriptpubkeys instead of addresses
        scriptpubkey1 = CScript([OP_TRUE, OP_DROP])
        scriptpubkey2 = CScript([OP_TRUE, OP_TRUE, OP_DROP, OP_DROP])
        scripthash1 = script_to_scripthash(lock(scriptpubkey1))

        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

        # Step 1: Create funding transaction (creates output in block N)
        self.info("Step 1: Creating output in block N")
        fee = 200
        # Ensure we send at least 10 * COIN for Nexa
        min_value = 10 * COIN
        tx1_value = max(coinbase.vout[0].n_value - fee, min_value)
        tx1 = self.create_transaction(
            coinbase,
            n=0,
            value=tx1_value,
            sig=anyonecanspend_unlocking_script(),
            out=lock(scriptpubkey1),
        )
        pad_tx(tx1)
        # txid1_hex unused - kept for potential debugging
        _ = tx1.get_rpc_hex_id()  # pylint: disable=unused-variable
        await cli.call("blockchain.transaction.broadcast", to_hex(tx1))
        await self.wait_for_mempool_count(cli, count=1)
        await self.sync_mempool(cli)

        # Step 2: Mine block N to confirm the output (use node to avoid P2P issues)
        self.info("Step 2: Mining block N to confirm output")
        n.generate(1)
        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

        # Verify address1 has balance
        balance1 = await cli.call("blockchain.scripthash.get_balance", scripthash1)
        assert_equal(
            tx1_value,
            balance1["confirmed"],
            "scripthash1 should have expected coins confirmed",
        )
        assert_equal(
            0, balance1["unconfirmed"], "scripthash1 should have 0 unconfirmed"
        )

        # Step 3: Create spending transaction (spends output in block N+1)
        self.info("Step 3: Spending output in block N+1")
        # Store the original balance before spending
        original_balance = balance1["confirmed"]

        # Create deterministic spending transaction that spends from tx1's output
        # Send at least 10 * COIN for Nexa
        spend_value = max(tx1.vout[0].n_value - fee, 10 * COIN)
        tx2 = self.create_transaction(
            tx1,
            n=0,
            value=spend_value,
            sig=unlock(scriptpubkey1),
            out=lock(scriptpubkey2),
        )
        pad_tx(tx2)
        # txid2_hex unused - kept for potential debugging
        _ = tx2.get_rpc_hex_id()  # pylint: disable=unused-variable
        await cli.call("blockchain.transaction.broadcast", to_hex(tx2))
        await self.wait_for_mempool_count(cli, count=1)
        await self.sync_mempool(cli)

        # Step 4: Mine block N+1 to confirm the spending (use node to avoid P2P issues)
        self.info("Step 4: Mining block N+1 to confirm spending")
        n.generate(1)
        block_n_plus_1 = n.getbestblockhash()
        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

        # Verify balance changed (output was spent)
        balance1_after_spend = await cli.call(
            "blockchain.scripthash.get_balance", scripthash1
        )
        # The balance should be different from original (output was spent)
        assert (
            balance1_after_spend["confirmed"] != original_balance
        ), "scripthash1 balance should change after spending transaction"

        # Step 5: Invalidate block N+1 (reorg)
        self.info(
            "Step 5: Invalidating block N+1 to trigger reorg and test unspent index recreation"
        )
        n.invalidateblock(block_n_plus_1)
        # Clear cached block hashes after reorg to avoid using stale ancestor hashes
        self.hash_at_height.clear()
        # Sync height (reorg processing) - don't sync mempool as spending tx may be back in mempool
        await self.sync_height(cli)
        # Give rostrum a moment to process the reorg
        await asyncio.sleep(1)

        # Step 6: Verify balance is restored (unspent index was recreated)
        self.info("Step 6: Verifying unspent index was recreated")
        balance1_after_reorg = await cli.call(
            "blockchain.scripthash.get_balance", scripthash1
        )
        # After reorg, the spending transaction is undone, so confirmed balance should be restored
        # This is the key assertion: if unspent index is not recreated, this will fail
        assert_equal(
            original_balance,
            balance1_after_reorg["confirmed"],
            f"scripthash1 should have {original_balance} confirmed after reorg "
            "(unspent index should be recreated - output from block N should be restored)",
        )
        # Note: unconfirmed balance might be negative if the spending transaction
        # is back in mempool, which is expected behavior after a reorg

        # Cleaning up after test
        n.generate(1)
        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

    def _has_tx(self, history, txid):
        """Helper to check if a txid is in history"""
        for entry in history:
            if entry["tx_hash"] == txid:
                return True
        return False

    def _confirmed_history(self, history):
        """Helper to get confirmed history (height > 0)"""
        return [h for h in history if h["height"] > 0]

    async def _create_and_confirm_funding_tx(  # pylint: disable=too-many-arguments,too-many-positional-arguments
        self, cli, n, coinbase, scriptpubkey1, scripthash1
    ):
        """Create and confirm a funding transaction, return tx and txid"""
        self.info("Step 1: Creating output in block N")
        fee = 200
        tx1 = self.create_transaction(
            coinbase,
            n=0,
            value=coinbase.vout[0].n_value - fee,
            sig=anyonecanspend_unlocking_script(),
            out=lock(scriptpubkey1),
        )
        pad_tx(tx1)
        txid1_hex = tx1.get_rpc_hex_id()
        await cli.call("blockchain.transaction.broadcast", to_hex(tx1))
        await self.wait_for_mempool_count(cli, count=1)
        await self.sync_mempool(cli)

        self.info(
            "Step 2: Mining block N to confirm output (use node to avoid P2P issues)"
        )
        n.generate(1)
        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

        # Wait for the funding transaction to appear in confirmed history
        confirmed_hist1 = None

        async def wait_for_conf():
            nonlocal confirmed_hist1
            history1 = await cli.call("blockchain.scripthash.get_history", scripthash1)
            confirmed_hist1 = self._confirmed_history(history1)
            # Check if our specific transaction is in the confirmed history
            return self._has_tx(confirmed_hist1, txid1_hex)

        await wait_for(
            10, wait_for_conf, "funding transaction should appear in confirmed history"
        )

        # Verify our transaction is in the confirmed history
        # Note: We don't assert exactly 1 transaction because the same scripthash
        # may be used in other tests, so there could be more transactions
        assert self._has_tx(
            confirmed_hist1, txid1_hex
        ), "history should contain funding tx"
        return tx1, txid1_hex

    async def _create_and_confirm_spending_tx(  # pylint: disable=too-many-arguments,too-many-positional-arguments
        self, cli, n, tx1, scriptpubkey1, scriptpubkey2, scripthash1, txid1_hex
    ):
        """Create and confirm a spending transaction, return txid and block hash"""
        self.info("Step 3: Spending output in block N+1")
        fee = 200
        tx2 = self.create_transaction(
            tx1,
            n=0,
            value=tx1.vout[0].n_value - fee,
            sig=unlock(scriptpubkey1),
            out=lock(scriptpubkey2),
        )
        pad_tx(tx2)
        txid2_hex = tx2.get_rpc_hex_id()
        await cli.call("blockchain.transaction.broadcast", to_hex(tx2))
        await self.wait_for_mempool_count(cli, count=1)
        await self.sync_mempool(cli)

        self.info(
            "Step 4: Mining block N+1 to confirm spending (use node to avoid P2P issues)"
        )
        n.generate(1)
        block_n_plus_1 = n.getbestblockhash()
        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

        # Wait for the spending transaction to appear in confirmed history
        confirmed_hist2 = None

        async def wait_for_spending():
            nonlocal confirmed_hist2
            history2 = await cli.call("blockchain.scripthash.get_history", scripthash1)
            confirmed_hist2 = self._confirmed_history(history2)
            # Check if both our transactions are in the confirmed history
            return self._has_tx(confirmed_hist2, txid1_hex) and self._has_tx(
                confirmed_hist2, txid2_hex
            )

        await wait_for(
            10,
            wait_for_spending,
            "both funding and spending transactions should appear in confirmed history",
        )

        # Verify both our transactions are in the confirmed history
        # Note: We don't assert exactly 2 transactions because the same scripthash
        # may be used in other tests, so there could be more transactions
        assert self._has_tx(
            confirmed_hist2, txid1_hex
        ), "history should contain funding tx"
        assert self._has_tx(
            confirmed_hist2, txid2_hex
        ), "history should contain spending tx"
        return txid2_hex, block_n_plus_1

    async def test_reorg_scripthash_history(self, cli, n, coinbases):
        """
        Test that scripthash history is correctly updated after reorgs.

        This verifies that when a block is undone:
        - Funding transactions are correctly restored in history
        - Spending transactions are correctly removed from confirmed history
        - The scripthash index is properly maintained
        """
        coinbase = coinbases.pop(0)

        scriptpubkey1 = CScript([OP_TRUE, OP_DROP])
        scriptpubkey2 = CScript([OP_TRUE, OP_TRUE, OP_DROP, OP_DROP])
        scripthash1 = script_to_scripthash(lock(scriptpubkey1))
        _ = script_to_scripthash(lock(scriptpubkey2))

        await self.sync_height(cli)
        await self.wait_for_mempool_count(cli, count=0)

        tx1, txid1_hex = await self._create_and_confirm_funding_tx(
            cli, n, coinbase, scriptpubkey1, scripthash1
        )

        txid2_hex, block_n_plus_1 = await self._create_and_confirm_spending_tx(
            cli, n, tx1, scriptpubkey1, scriptpubkey2, scripthash1, txid1_hex
        )

        self.info("Step 5: Invalidating block N+1 to trigger reorg")
        n.invalidateblock(block_n_plus_1)
        # Clear cached block hashes after reorg to avoid using stale ancestor hashes
        self.hash_at_height.clear()
        await self.sync_height(cli)
        await asyncio.sleep(1)

        self.info("Step 6: Verifying history after reorg")
        history3 = await cli.call("blockchain.scripthash.get_history", scripthash1)
        confirmed_hist3 = self._confirmed_history(history3)

        # Verify the funding transaction is still in confirmed history
        # Note: We don't assert exactly 1 transaction because the same scripthash
        # may be used in other tests, so there could be more transactions
        assert self._has_tx(
            confirmed_hist3, txid1_hex
        ), "history should still contain funding tx"
        # Verify the spending transaction is NOT in confirmed history (it was undone by reorg)
        assert not self._has_tx(
            confirmed_hist3, txid2_hex
        ), "history should NOT contain spending tx in confirmed (it was undone)"

        self.info("✓ Test passed: Scripthash history correctly updated after reorg")


if __name__ == "__main__":
    asyncio.run(ElectrumReorgTests().main())