git / moonbite-network / tests/test_transaction.py · raw
"""Tests for Milestone 1 — Transactions."""import sysfrom pathlib import Pathimport pytestsys.path.insert(0, str(Path(__file__).resolve().parent.parent))from transaction import ( # noqa: E402 Transaction, TxInput, TxOutput, generate_keypair, pubkey_hash,)class FakeChain: """A tiny in-memory output resolver standing in for the future UTXO set. Register a transaction's outputs, then resolve (txid, index) -> TxOutput. """ def __init__(self): self._outputs: dict[tuple[str, int], TxOutput] = {} def add_tx(self, tx: Transaction) -> None: for i, out in enumerate(tx.outputs): self._outputs[(tx.txid, i)] = out def spend(self, txid: str, index: int) -> None: self._outputs.pop((txid, index), None) def resolve(self, txid: str, index: int): return self._outputs.get((txid, index))@pytest.fixturedef alice(): sk, pub = generate_keypair() return {"sk": sk, "pub": pub, "pkh": pubkey_hash(pub)}@pytest.fixturedef bob(): sk, pub = generate_keypair() return {"sk": sk, "pub": pub, "pkh": pubkey_hash(pub)}def _funding_tx(owner_pkh: str, amount: int = 100) -> Transaction: """A pre-existing output paying `amount` to `owner_pkh` (no inputs here so the fixture is self-contained; verify() is not called on this stub).""" return Transaction(inputs=[], outputs=[TxOutput(amount, owner_pkh)])def test_txid_is_deterministic_and_changes_with_content(alice): tx = _funding_tx(alice["pkh"]) assert tx.txid == tx.txid # deterministic tx2 = _funding_tx(alice["pkh"], amount=101) assert tx.txid != tx2.txiddef test_signing_bytes_excludes_signatures(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0)], outputs=[TxOutput(100, bob["pkh"])], ) before = spend.signing_bytes() spend.sign_input(0, alice["sk"]) after = spend.signing_bytes() assert before == after # signing must not change the signed messagedef test_valid_single_input_single_output(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0)], outputs=[TxOutput(100, bob["pkh"])], ) spend.sign_input(0, alice["sk"]) assert spend.verify(chain.resolve) is True assert spend.fee(chain.resolve) == 0def test_change_output_back_to_sender(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0)], outputs=[TxOutput(70, bob["pkh"]), TxOutput(25, alice["pkh"])], ) spend.sign_input(0, alice["sk"]) assert spend.verify(chain.resolve) is True assert spend.fee(chain.resolve) == 5 # 100 - (70 + 25)def test_multiple_inputs(alice, bob): chain = FakeChain() f1 = _funding_tx(alice["pkh"], 40) f2 = _funding_tx(alice["pkh"], 60) chain.add_tx(f1) chain.add_tx(f2) spend = Transaction( inputs=[TxInput(f1.txid, 0), TxInput(f2.txid, 0)], outputs=[TxOutput(100, bob["pkh"])], ) spend.sign_input(0, alice["sk"]) spend.sign_input(1, alice["sk"]) assert spend.verify(chain.resolve) is Truedef test_wrong_key_fails(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) # locked to Alice chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0)], outputs=[TxOutput(100, bob["pkh"])], ) spend.sign_input(0, bob["sk"]) # Bob cannot spend Alice's output assert spend.verify(chain.resolve) is Falsedef test_tampered_output_breaks_signature(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0)], outputs=[TxOutput(100, bob["pkh"])], ) spend.sign_input(0, alice["sk"]) spend.outputs[0].amount = 999 # attacker inflates the payment after signing assert spend.verify(chain.resolve) is Falsedef test_overspend_rejected(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0)], outputs=[TxOutput(150, bob["pkh"])], # more than the 100 available ) spend.sign_input(0, alice["sk"]) assert spend.verify(chain.resolve) is Falsedef test_unknown_or_spent_output_rejected(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0)], outputs=[TxOutput(100, bob["pkh"])], ) spend.sign_input(0, alice["sk"]) chain.spend(funding.txid, 0) # output already consumed assert spend.verify(chain.resolve) is Falsedef test_duplicate_input_within_tx_rejected(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0), TxInput(funding.txid, 0)], outputs=[TxOutput(200, bob["pkh"])], ) spend.sign_input(0, alice["sk"]) spend.sign_input(1, alice["sk"]) assert spend.verify(chain.resolve) is Falsedef test_coinbase_shape_not_verifiable_here(bob): from transaction import COINBASE_PREV_TXID coinbase = Transaction( inputs=[TxInput(COINBASE_PREV_TXID, 7)], # null prevout, height in index outputs=[TxOutput(50, bob["pkh"])], ) assert coinbase.is_coinbase() is True # A coinbase is not spendable-input-verifiable; it's validated in M5/node. assert coinbase.verify(lambda t, i: None) is False # An input-less tx is neither a coinbase nor verifiable. empty = Transaction(inputs=[], outputs=[TxOutput(50, bob["pkh"])]) assert empty.is_coinbase() is False assert empty.verify(lambda t, i: None) is Falsedef test_serialization_round_trip(alice, bob): chain = FakeChain() funding = _funding_tx(alice["pkh"], 100) chain.add_tx(funding) spend = Transaction( inputs=[TxInput(funding.txid, 0)], outputs=[TxOutput(100, bob["pkh"])], ) spend.sign_input(0, alice["sk"]) restored = Transaction.from_dict(spend.to_dict()) assert restored.txid == spend.txid assert restored.verify(chain.resolve) is True