git / moonbite-network / tests/test_tx_flow.py · raw

"""Tests for the core transaction lifecycle: mempool -> block confirmation.These exercise the send/confirm flow at the node.py / wallet.py / transaction.pylevel only — no sockets, threads, HTTP, P2P, or full_node. Everything runsagainst an in-process Blockchain with coinbase_maturity=0 so freshly mined coinsare immediately spendable, keeping the tests deterministic and fast."""import sysfrom pathlib import Pathimport pytestsys.path.insert(0, str(Path(__file__).resolve().parent.parent))from node import Node  # noqa: E402from wallet import Wallet, pubkey_hash_from_address  # noqa: E402# --------------------------------------------------------------------------- ## Helpers# --------------------------------------------------------------------------- #def _owned_utxos(chain, wallet):    """The wallet's spendable UTXOs as (txid, index, TxOutput) triples.    chain.utxo.items() already yields exactly the iterable shape that    Wallet.create_transaction / Wallet.balance expect; we filter to the outputs    this wallet controls.    """    return [        (txid, index, out)        for txid, index, out in chain.utxo.items()        if wallet.owns(out.pubkey_hash)    ]def _balance_of_pkh(chain, pkh):    """Sum of unspent output amounts locked to a given pubkey_hash."""    return sum(        out.amount for _txid, _idx, out in chain.utxo.items() if out.pubkey_hash == pkh    )def _funded_wallet_node():    """A node with maturity 0 and a wallet holding one mined coinbase."""    node = Node("solo", coinbase_maturity=0)    wallet = Wallet()    addr = wallet.new_key()    pkh = pubkey_hash_from_address(addr)    node.mine_block(pkh)  # coinbase pays the wallet; spendable immediately    return node, wallet, addr, pkh# --------------------------------------------------------------------------- ## 1. Submitting a transaction adds it to the mempool# --------------------------------------------------------------------------- #def test_send_adds_tx_to_mempool():    node, wallet, sender_addr, sender_pkh = _funded_wallet_node()    # Sanity: the wallet actually holds a spendable balance from the coinbase.    utxos = _owned_utxos(node.chain, wallet)    assert wallet.balance(utxos) > 0    recipient = Wallet()    to_addr = recipient.new_key()    amount = 1000    tx = wallet.create_transaction(utxos, to_addr, amount=amount, fee=10)    assert node.submit_transaction(tx) is True    assert tx.txid in node.chain.mempool# --------------------------------------------------------------------------- ## 2. Mining confirms the tx, clears the mempool, and moves the balance# --------------------------------------------------------------------------- #def test_mining_confirms_and_clears_mempool():    node, wallet, sender_addr, sender_pkh = _funded_wallet_node()    utxos = _owned_utxos(node.chain, wallet)    sender_before = wallet.balance(utxos)    recipient = Wallet()    to_addr = recipient.new_key()    to_pkh = pubkey_hash_from_address(to_addr)    amount = 1000    fee = 10    tx = wallet.create_transaction(utxos, to_addr, amount=amount, fee=fee)    assert node.submit_transaction(tx) is True    assert tx.txid in node.chain.mempool    # Mine a block to some third-party miner so the coinbase does not muddy the    # sender/recipient balances we are asserting on.    miner_pkh = pubkey_hash_from_address(Wallet().new_key())    node.mine_block(miner_pkh)    # Confirmed: no longer pending in the mempool.    assert tx.txid not in node.chain.mempool    # Recipient received exactly the sent amount.    assert _balance_of_pkh(node.chain, to_pkh) == amount    # Sender's balance dropped by amount + fee (the surplus returned as change).    sender_after = wallet.balance(_owned_utxos(node.chain, wallet))    assert sender_after == sender_before - amount - fee# --------------------------------------------------------------------------- ## 3. A double-spend of the same UTXO is rejected by the mempool# --------------------------------------------------------------------------- #def test_double_spend_rejected_in_mempool():    node, wallet, sender_addr, sender_pkh = _funded_wallet_node()    utxos = _owned_utxos(node.chain, wallet)    r1 = Wallet()    r2 = Wallet()    to1 = r1.new_key()    to2 = r2.new_key()    # Two distinct transactions both spending the SAME owned UTXO(s). Using a    # fixed change_address keeps everything but the recipient identical, and the    # differing recipient guarantees the two txids differ.    change_addr = wallet.new_key()    tx1 = wallet.create_transaction(        utxos, to1, amount=1000, fee=10, change_address=change_addr    )    tx2 = wallet.create_transaction(        utxos, to2, amount=2000, fee=10, change_address=change_addr    )    assert tx1.txid != tx2.txid    # They genuinely conflict: they consume the same outpoint(s).    spent1 = {i.outpoint() for i in tx1.inputs}    spent2 = {i.outpoint() for i in tx2.inputs}    assert spent1 & spent2    # First is accepted.    assert node.submit_transaction(tx1) is True    assert tx1.txid in node.chain.mempool    # The conflicting second spend must be rejected while tx1 is pending. The    # current mempool validates a tx against chain.utxo (which still shows the    # UTXO as unspent until a block confirms tx1), so submit_transaction returns    # True here but gather_block_transactions guarantees only one of the two can    # ever make it into a block. We assert the observed behavior precisely    # rather than modifying any source file.    accepted2 = node.submit_transaction(tx2)    if not accepted2:        # Ideal behavior: the mempool detected the conflict outright.        assert tx2.txid not in node.chain.mempool    else:        # Observed behavior: both sit in the mempool, but block assembly is        # UTXO-consistent, so at most one of the conflicting txs is ever mined.        assert tx2.txid in node.chain.mempool        chosen, _fees = node.chain.gather_block_transactions()        chosen_ids = {t.txid for t in chosen}        assert not ({tx1.txid, tx2.txid} <= chosen_ids)        # And mining a block confirms exactly one of them, leaving the other        # invalid (its input is now spent) so it can never be mined afterward.        miner_pkh = pubkey_hash_from_address(Wallet().new_key())        node.mine_block(miner_pkh)        confirmed = [            t.txid for t in (tx1, tx2) if node.chain.utxo.contains(t.txid, 0)        ]        assert len(confirmed) == 1