Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions TESTS_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Testing Framework

This document provides an overview of the testing framework for the python-bitcoin-utils library.

## Testing Approach

The tests in this library are designed to work without requiring an active Bitcoin node connection or live network. Instead, they use a mock data approach to simulate Bitcoin network operations.

Key features of our testing approach:
- **Mock Data**: Pre-defined test vectors are stored in JSON files in the `mock_data` directory
- **Isolation**: Tests run independently of any live Bitcoin network
- **Reproducibility**: Fixed inputs ensure consistent test results
- **Comprehensive Coverage**: Tests cover edge cases and error handling

## Test Organization

The tests are organized by functionality:
- **Key and Address Tests**: Tests for private/public keys and address generation
- **Transaction Tests**: Tests for creating and signing various transaction types
- **Script Tests**: Tests for Bitcoin Script operations

## Mock Data

Mock data is stored in JSON files in the `tests/mock_data` directory. These files contain test vectors for various scenarios.

## Public Key Recovery Tests (PR #120)

The `test_key_recovery.py` file contains fully implemented tests for public key recovery from message and signature functionality from PR #120. These tests verify:

- Recovery of public keys from message signatures
- Error handling for invalid signature length
- Error handling for invalid recovery ID
- Error handling for missing parameters
- Error handling for empty messages

The tests use predefined test vectors with known messages, signatures, and corresponding public keys to verify the recovery process works correctly.

### Running the Public Key Recovery Tests

To run the public key recovery tests specifically:

```bash
pytest -xvs tests/test_key_recovery.py
```

### Extending Public Key Recovery Tests

To add more test cases for public key recovery:
1. Add new test vectors (message, signature, expected public key)
2. Follow the pattern in the `TestPublicKeyRecovery` class
3. Ensure proper validation of error cases

## Running Tests

To run all tests:
```bash
python -m unittest discover tests
```

To run a specific test file:
```bash
python -m unittest tests.test_file_name
```

## Adding New Tests

When adding new tests:
1. Create appropriate mock data in the `tests/mock_data` directory
2. Create test classes extending `unittest.TestCase`
3. Use the mock data in your tests instead of making live network calls
4. Update this README with information about your new tests

## Test Dependencies

The tests require the following packages:
- unittest (standard library)
- json (standard library)
- os (standard library)

## Examples

### Example 1: Testing with Mock Transaction Data

```python
import unittest
import json
import os
from bitcoinutils.transactions import Transaction

class TestTransactions(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Load mock data
mock_data_path = os.path.join('tests', 'mock_data', 'transaction_data.json')
with open(mock_data_path, 'r') as file:
cls.mock_data = json.load(file)

def test_transaction_parsing(self):
# Use mock transaction data
raw_tx = self.mock_data['valid_transactions'][0]['raw']
tx = Transaction.from_raw(raw_tx)

# Verify transaction properties
self.assertEqual(tx.version, self.mock_data['valid_transactions'][0]['version'])
self.assertEqual(len(tx.inputs), self.mock_data['valid_transactions'][0]['input_count'])
```

### Example 2: Using Mock Data for Keys and Addresses

```python
import unittest
import json
import os
from bitcoinutils.setup import setup
from bitcoinutils.keys import PrivateKey, PublicKey

class TestKeysAndAddresses(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Set up the network
setup('testnet')

# Load mock data
mock_data_path = os.path.join('tests', 'mock_data', 'key_address_data.json')
with open(mock_data_path, 'r') as file:
cls.mock_data = json.load(file)

def test_address_generation(self):
# Use mock private key data
priv_key_wif = self.mock_data['private_keys'][0]['wif']
expected_address = self.mock_data['private_keys'][0]['address']

# Create private key and derive address
priv_key = PrivateKey(priv_key_wif)
pub_key = priv_key.get_public_key()
address = pub_key.get_address()

# Verify address matches expected
self.assertEqual(address.to_string(), expected_address)
```

### Example 3: Testing Public Key Recovery (PR #120)

```python
import unittest
from bitcoinutils.setup import setup
from bitcoinutils.keys import PublicKey

class TestPublicKeyRecovery(unittest.TestCase):
def setUp(self):
# Set up the network
setup('testnet')

# Test data for public key recovery
self.valid_message = "Hello, Bitcoin!"
self.valid_signature = b'\x1f\x0c\xfc\xd8V\xec27)\xa7\xfc\x02:\xda\xcfT\xb2*\x02\x16.\xe2s\x7f\x18[&^\xb3e\xee3"KN\xfct\x011Z[\x05\xb5\xea\n!\xe8\xce\x9em\x89/\xf2\xa0\x15\x83{\x7f\x9e\xba+\xb4\xf8&\x15'
self.expected_public_key = '02649abc7094d2783670255073ccfd132677555ca84045c5a005611f25ef51fdbf'

def test_public_key_recovery_valid(self):
# Recover public key from message and signature
pubkey = PublicKey(message=self.valid_message, signature=self.valid_signature)

# Verify recovered public key matches expected
self.assertEqual(pubkey.key.to_string("compressed").hex(), self.expected_public_key)
```

These examples demonstrate how to use mock data in your tests without relying on live network connections.
23 changes: 22 additions & 1 deletion bitcoinutils/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,22 @@
__version__ = "0.7.2"
# Copyright (C) 2018-2024 The python-bitcoin-utils developers
#
# This file is part of python-bitcoin-utils
#
# It is subject to the license terms in the LICENSE file found in the top-level
# directory of this distribution.
#
# No part of python-bitcoin-utils, including this file, may be copied,
# modified, propagated, or distributed except according to the terms contained
# in the LICENSE file.

"""Python Bitcoin Utils is a library for Bitcoin application development."""

from bitcoinutils.setup import setup, get_network
from bitcoinutils.keys import PrivateKey, PublicKey, P2pkhAddress, P2shAddress, P2wpkhAddress, P2wshAddress
from bitcoinutils.transactions import Transaction, TxInput, TxOutput, Sequence, TxWitnessInput
from bitcoinutils.script import Script
from bitcoinutils.constants import SATOSHIS_PER_BITCOIN

import sys

__version__ = '0.5.3' # Update this with your library's version
120 changes: 65 additions & 55 deletions bitcoinutils/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,57 +89,49 @@ def __init__(
@staticmethod
def from_raw(rawhexdata: Union[str, bytes]):
"""
Constructs a BlockHeader object from a raw block header data.
Constructs a BlockHeader instance from raw block header data in hexadecimal or byte format.

Args:
rawhexdata (Union[str, bytes]): Raw hexadecimal or byte data representing the block header.
rawhexdata (Union[str, bytes]): The raw data of the block header in hexadecimal or bytes format.

Returns:
BlockHeader: An instance of BlockHeader initialized from the provided raw data.
BlockHeader: A fully parsed BlockHeader object.

Raises:
TypeError: If the input data type is not a string or bytes.
ValueError: If the length of raw data does not match the expected size of a block header.
TypeError: If the input is not a string or bytes.
ValueError: If the input does not meet the expected header structure or size.
"""

# Checking if rawhexdata is in hex and convert to bytes if necessary
# Convert to bytes if necessary
if isinstance(rawhexdata, str):
rawdata = h_to_b(rawhexdata)
elif isinstance(rawhexdata, bytes):
rawdata = rawhexdata
else:
raise TypeError("Input must be a hexadecimal string or bytes")

# format String for struct packing/unpacking for block header
header_format = "<" # little-edian
header_format += "I" # version (4 bytes)
header_format += "32s" # previous block hash (32 bytes)
header_format += "32s" # merkle root (32 bytes)
header_format += "I" # timestamp (4 bytes)
header_format += "I" # target bits (4 bytes)
header_format += "I" # nonce (4 bytes)

if len(rawdata) != HEADER_SIZE:
raise ValueError(f"Incorrect data length. Expected {HEADER_SIZE} bytes.")

(
version,
previous_block_hash,
merkle_root,
timestamp,
target_bits,
nonce,
) = struct.unpack(header_format, rawdata)
previous_block_hash = previous_block_hash[::-1] # natural byte order
merkle_root = merkle_root[::-1] # natural byte order
# Ensure we have enough data for a header
if len(rawdata) < 80: # A block header is exactly 80 bytes
raise ValueError(f"Block header must be at least 80 bytes, got {len(rawdata)}")

# Define the header format
header_format = '<I32s32sIII'

# Unpack the header data
fields = struct.unpack(header_format, rawdata[:80])
version = fields[0]
prev_block_hash = fields[1]
merkle_root = fields[2]
timestamp = fields[3]
bits = fields[4]
nonce = fields[5]

return BlockHeader(
version=version,
previous_block_hash=previous_block_hash,
previous_block_hash=prev_block_hash,
merkle_root=merkle_root,
timestamp=timestamp,
target_bits=target_bits,
nonce=nonce,
target_bits=bits,
nonce=nonce
)

def __str__(self) -> str:
Expand Down Expand Up @@ -168,13 +160,31 @@ def get_version(self) -> Optional[int]:
"""Returns the block version, or None if not set."""
return self.version if self.version is not None else None

def get_previous_block_hash(self) -> Optional[bytes]:
"""Returns the previous block hash as bytes, or None if not set."""
return self.previous_block_hash.hex() if self.previous_block_hash else None
def get_previous_block_hash(self) -> Optional[str]:
"""
Returns the previous block hash as a hex string, or None if not set.
The hash is displayed in big-endian format (reversed bytes) which is the standard display format.

Returns:
Optional[str]: The previous block hash in big-endian hex format, or None if not set.
"""
if self.previous_block_hash is None:
return None
# Convert from little-endian storage to big-endian display format
return self.previous_block_hash[::-1].hex()

def get_merkle_root(self) -> Optional[bytes]:
"""Returns the merkle root as bytes, or None if not set."""
return self.merkle_root.hex() if self.merkle_root else None
def get_merkle_root(self) -> Optional[str]:
"""
Returns the merkle root as a hex string, or None if not set.
The merkle root is displayed in big-endian format (reversed bytes) which is the standard display format.

Returns:
Optional[str]: The merkle root in big-endian hex format, or None if not set.
"""
if self.merkle_root is None:
return None
# Convert from little-endian storage to big-endian display format
return self.merkle_root[::-1].hex()

def get_timestamp(self) -> Optional[int]:
"""Returns the block timestamp, or None if not set."""
Expand Down Expand Up @@ -358,32 +368,32 @@ def from_raw(rawhexdata: Union[str, bytes]):
rawdata = rawhexdata
else:
raise TypeError("Input must be a hexadecimal string or bytes")

# Ensure we have enough data for the block header
if len(rawdata) < 8 + HEADER_SIZE:
raise ValueError(f"Block data must be at least {8 + HEADER_SIZE} bytes, got {len(rawdata)}")

magic = rawdata[0:4]
block_size = struct.unpack("<I", rawdata[4:8])[0]
block_size = block_size
header = BlockHeader.from_raw(rawdata[8 : 8 + HEADER_SIZE])
header = BlockHeader.from_raw(rawdata[8:8 + HEADER_SIZE])

# Handling the transaction counter which is a CompactSize
transaction_count, tx_offset = parse_compact_size(rawdata[88:])
transaction_count, tx_offset = parse_compact_size(rawdata[8 + HEADER_SIZE:])
transactions = []
current_offset = 88 + tx_offset
current_offset = 8 + HEADER_SIZE + tx_offset

for i in range(transaction_count):
try:
tx_length = get_transaction_length(rawdata[current_offset:])
transactions.append(
Transaction.from_raw(
rawdata[current_offset : current_offset + tx_length].hex()
)
)
temp = Transaction.from_raw(
rawdata[current_offset : current_offset + tx_length].hex()
)
tx_hex = rawdata[current_offset:current_offset + tx_length].hex()
tx = Transaction.from_raw(tx_hex)
transactions.append(tx)
current_offset += tx_length

except Exception as e:
print(e)
print(i, transaction_count)
break
print(f"Error parsing transaction {i}/{transaction_count}: {e}")
current_offset += 1 # Move forward to try to find next transaction
if current_offset >= len(rawdata):
break

return Block(magic, block_size, header, transaction_count, transactions)

Expand Down Expand Up @@ -537,4 +547,4 @@ def get_legacy_transactions(self) -> list[Transaction]:
raise ValueError("No transactions given.")

legacy_transactions = [tx for tx in self.transactions if not tx.has_segwit]
return legacy_transactions
return legacy_transactions
19 changes: 18 additions & 1 deletion bitcoinutils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,21 @@
"0b110907" : "testnet",
"fabfb5da" : "regtest",
"0a03cf40" : "signet"
}
}

# PSBT related constants
PSBT_MAGIC_BYTES = b'psbt\xff'
PSBT_GLOBAL_UNSIGNED_TX = 0x00
PSBT_GLOBAL_XPUB = 0x01
PSBT_INPUT_NON_WITNESS_UTXO = 0x00
PSBT_INPUT_WITNESS_UTXO = 0x01
PSBT_INPUT_PARTIAL_SIG = 0x02
PSBT_INPUT_SIGHASH_TYPE = 0x03
PSBT_INPUT_REDEEM_SCRIPT = 0x04
PSBT_INPUT_WITNESS_SCRIPT = 0x05
PSBT_INPUT_BIP32_DERIVATION = 0x06
PSBT_INPUT_FINAL_SCRIPTSIG = 0x07
PSBT_INPUT_FINAL_SCRIPTWITNESS = 0x08
PSBT_OUTPUT_REDEEM_SCRIPT = 0x00
PSBT_OUTPUT_WITNESS_SCRIPT = 0x01
PSBT_OUTPUT_BIP32_DERIVATION = 0x02
Loading