"""Tests for chain synchronization, conflict resolution, and signature validation.""" import hashlib import time import sys import pytest from datetime import datetime from contextlib import contextmanager from sqlmodel import Session, SQLModel, create_engine, select from aitbc_chain.models import Block, Transaction from aitbc_chain.metrics import metrics_registry from aitbc_chain.sync import ChainSync, ProposerSignatureValidator, ImportResult @pytest.fixture(autouse=True) def reset_metrics(): metrics_registry.reset() yield metrics_registry.reset() @pytest.fixture def db_engine(tmp_path): db_path = tmp_path / "test_sync.db" engine = create_engine(f"sqlite:///{db_path}", echo=False) SQLModel.metadata.create_all(engine) return engine @pytest.fixture def session_factory(db_engine): @contextmanager def _factory(): with Session(db_engine) as session: yield session return _factory def _make_block_hash(chain_id, height, parent_hash, timestamp): payload = f"{chain_id}|{height}|{parent_hash}|{timestamp.isoformat()}".encode() return "0x" + hashlib.sha256(payload).hexdigest() def _seed_chain(session_factory, count=5, chain_id="test-chain", proposer="proposer-a"): """Seed a chain with `count` blocks.""" parent_hash = "0x00" blocks = [] with session_factory() as session: for h in range(count): ts = datetime(2026, 1, 1, 0, 0, h) bh = _make_block_hash(chain_id, h, parent_hash, ts) block = Block( chain_id=chain_id, height=h, hash=bh, parent_hash=parent_hash, proposer=proposer, timestamp=ts, tx_count=0, ) session.add(block) blocks.append({"height": h, "hash": bh, "parent_hash": parent_hash, "proposer": proposer, "timestamp": ts.isoformat()}) parent_hash = bh session.commit() return blocks class TestProposerSignatureValidator: def test_valid_block(self): v = ProposerSignatureValidator() ts = datetime.utcnow() bh = _make_block_hash("test", 1, "0x00", ts) ok, reason = v.validate_block_signature({ "height": 1, "hash": bh, "parent_hash": "0x00", "proposer": "node-a", "timestamp": ts.isoformat(), }) assert ok is True assert reason == "Valid" def test_missing_proposer(self): v = ProposerSignatureValidator() ok, reason = v.validate_block_signature({ "height": 1, "hash": "0x" + "a" * 64, "parent_hash": "0x00", "timestamp": datetime.utcnow().isoformat(), }) assert ok is False assert "Missing proposer" in reason def test_invalid_hash_format(self): v = ProposerSignatureValidator() ok, reason = v.validate_block_signature({ "height": 1, "hash": "badhash", "parent_hash": "0x00", "proposer": "node-a", "timestamp": datetime.utcnow().isoformat(), }) assert ok is False assert "Invalid block hash" in reason def test_invalid_hash_length(self): v = ProposerSignatureValidator() ok, reason = v.validate_block_signature({ "height": 1, "hash": "0xabc", "parent_hash": "0x00", "proposer": "node-a", "timestamp": datetime.utcnow().isoformat(), }) assert ok is False assert "Invalid hash length" in reason def test_untrusted_proposer_rejected(self): v = ProposerSignatureValidator(trusted_proposers=["node-a", "node-b"]) ts = datetime.utcnow() bh = _make_block_hash("test", 1, "0x00", ts) ok, reason = v.validate_block_signature({ "height": 1, "hash": bh, "parent_hash": "0x00", "proposer": "node-evil", "timestamp": ts.isoformat(), }) assert ok is False assert "not in trusted set" in reason def test_trusted_proposer_accepted(self): v = ProposerSignatureValidator(trusted_proposers=["node-a"]) ts = datetime.utcnow() bh = _make_block_hash("test", 1, "0x00", ts) ok, reason = v.validate_block_signature({ "height": 1, "hash": bh, "parent_hash": "0x00", "proposer": "node-a", "timestamp": ts.isoformat(), }) assert ok is True def test_add_remove_trusted(self): v = ProposerSignatureValidator() assert len(v.trusted_proposers) == 0 v.add_trusted("node-x") assert "node-x" in v.trusted_proposers v.remove_trusted("node-x") assert "node-x" not in v.trusted_proposers def test_missing_required_field(self): v = ProposerSignatureValidator() ok, reason = v.validate_block_signature({ "hash": "0x" + "a" * 64, "proposer": "node-a", # missing height, parent_hash, timestamp }) assert ok is False assert "Missing required field" in reason class TestChainSyncAppend: def test_append_to_empty_chain(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) ts = datetime.utcnow() bh = _make_block_hash("test", 0, "0x00", ts) result = sync.import_block({ "height": 0, "hash": bh, "parent_hash": "0x00", "proposer": "node-a", "timestamp": ts.isoformat(), }) assert result.accepted is True assert result.height == 0 def test_append_sequential(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) blocks = _seed_chain(session_factory, count=3, chain_id="test") last = blocks[-1] ts = datetime(2026, 1, 1, 0, 0, 3) bh = _make_block_hash("test", 3, last["hash"], ts) result = sync.import_block({ "height": 3, "hash": bh, "parent_hash": last["hash"], "proposer": "node-a", "timestamp": ts.isoformat(), }) assert result.accepted is True assert result.height == 3 def test_duplicate_rejected(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) blocks = _seed_chain(session_factory, count=2, chain_id="test") result = sync.import_block({ "height": 0, "hash": blocks[0]["hash"], "parent_hash": "0x00", "proposer": "proposer-a", "timestamp": blocks[0]["timestamp"], }) assert result.accepted is False assert "already exists" in result.reason def test_stale_block_rejected(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) _seed_chain(session_factory, count=5, chain_id="test") ts = datetime(2026, 6, 1) bh = _make_block_hash("test", 2, "0x00", ts) result = sync.import_block({ "height": 2, "hash": bh, "parent_hash": "0x00", "proposer": "node-b", "timestamp": ts.isoformat(), }) assert result.accepted is False assert "Stale" in result.reason or "Fork" in result.reason or "longer" in result.reason def test_gap_detected(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) _seed_chain(session_factory, count=3, chain_id="test") ts = datetime(2026, 6, 1) bh = _make_block_hash("test", 10, "0x00", ts) result = sync.import_block({ "height": 10, "hash": bh, "parent_hash": "0x00", "proposer": "node-a", "timestamp": ts.isoformat(), }) assert result.accepted is False assert "Gap" in result.reason def test_append_with_transactions(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) blocks = _seed_chain(session_factory, count=1, chain_id="test") last = blocks[-1] ts = datetime(2026, 1, 1, 0, 0, 1) bh = _make_block_hash("test", 1, last["hash"], ts) txs = [ {"tx_hash": "0x" + "a" * 64, "sender": "alice", "recipient": "bob"}, {"tx_hash": "0x" + "b" * 64, "sender": "charlie", "recipient": "dave"}, ] result = sync.import_block({ "height": 1, "hash": bh, "parent_hash": last["hash"], "proposer": "node-a", "timestamp": ts.isoformat(), "tx_count": 2, }, transactions=txs) assert result.accepted is True # Verify transactions were stored with session_factory() as session: stored_txs = session.exec(select(Transaction).where(Transaction.block_height == 1)).all() assert len(stored_txs) == 2 class TestChainSyncSignatureValidation: def test_untrusted_proposer_rejected_on_import(self, session_factory): validator = ProposerSignatureValidator(trusted_proposers=["node-a"]) sync = ChainSync(session_factory, chain_id="test", validator=validator, validate_signatures=True) ts = datetime.utcnow() bh = _make_block_hash("test", 0, "0x00", ts) result = sync.import_block({ "height": 0, "hash": bh, "parent_hash": "0x00", "proposer": "node-evil", "timestamp": ts.isoformat(), }) assert result.accepted is False assert "not in trusted set" in result.reason def test_trusted_proposer_accepted_on_import(self, session_factory): validator = ProposerSignatureValidator(trusted_proposers=["node-a"]) sync = ChainSync(session_factory, chain_id="test", validator=validator, validate_signatures=True) ts = datetime.utcnow() bh = _make_block_hash("test", 0, "0x00", ts) result = sync.import_block({ "height": 0, "hash": bh, "parent_hash": "0x00", "proposer": "node-a", "timestamp": ts.isoformat(), }) assert result.accepted is True def test_validation_disabled(self, session_factory): validator = ProposerSignatureValidator(trusted_proposers=["node-a"]) sync = ChainSync(session_factory, chain_id="test", validator=validator, validate_signatures=False) ts = datetime.utcnow() bh = _make_block_hash("test", 0, "0x00", ts) result = sync.import_block({ "height": 0, "hash": bh, "parent_hash": "0x00", "proposer": "node-evil", "timestamp": ts.isoformat(), }) assert result.accepted is True # validation disabled class TestChainSyncConflictResolution: def test_fork_at_same_height_rejected(self, session_factory): """Fork at same height as our chain — our chain wins (equal length).""" sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) blocks = _seed_chain(session_factory, count=5, chain_id="test") # Try to import a different block at height 3 ts = datetime(2026, 6, 15) bh = _make_block_hash("test", 3, "0xdifferent", ts) result = sync.import_block({ "height": 3, "hash": bh, "parent_hash": "0xdifferent", "proposer": "node-b", "timestamp": ts.isoformat(), }) assert result.accepted is False assert "longer" in result.reason or "Fork" in result.reason def test_sync_status(self, session_factory): sync = ChainSync(session_factory, chain_id="test-chain", validate_signatures=False) _seed_chain(session_factory, count=5, chain_id="test-chain") status = sync.get_sync_status() assert status["chain_id"] == "test-chain" assert status["head_height"] == 4 assert status["total_blocks"] == 5 assert status["max_reorg_depth"] == 10 class TestSyncMetrics: def test_accepted_block_increments_metrics(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) ts = datetime.utcnow() bh = _make_block_hash("test", 0, "0x00", ts) sync.import_block({ "height": 0, "hash": bh, "parent_hash": "0x00", "proposer": "node-a", "timestamp": ts.isoformat(), }) prom = metrics_registry.render_prometheus() assert "sync_blocks_received_total" in prom assert "sync_blocks_accepted_total" in prom def test_rejected_block_increments_metrics(self, session_factory): validator = ProposerSignatureValidator(trusted_proposers=["node-a"]) sync = ChainSync(session_factory, chain_id="test", validator=validator, validate_signatures=True) ts = datetime.utcnow() bh = _make_block_hash("test", 0, "0x00", ts) sync.import_block({ "height": 0, "hash": bh, "parent_hash": "0x00", "proposer": "node-evil", "timestamp": ts.isoformat(), }) prom = metrics_registry.render_prometheus() assert "sync_blocks_rejected_total" in prom def test_duplicate_increments_metrics(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) _seed_chain(session_factory, count=1, chain_id="test") with session_factory() as session: block = session.exec(select(Block).where(Block.height == 0)).first() sync.import_block({ "height": 0, "hash": block.hash, "parent_hash": "0x00", "proposer": "proposer-a", "timestamp": block.timestamp.isoformat(), }) prom = metrics_registry.render_prometheus() assert "sync_blocks_duplicate_total" in prom def test_fork_increments_metrics(self, session_factory): sync = ChainSync(session_factory, chain_id="test", validate_signatures=False) _seed_chain(session_factory, count=5, chain_id="test") ts = datetime(2026, 6, 15) bh = _make_block_hash("test", 3, "0xdifferent", ts) sync.import_block({ "height": 3, "hash": bh, "parent_hash": "0xdifferent", "proposer": "node-b", "timestamp": ts.isoformat(), }) prom = metrics_registry.render_prometheus() assert "sync_forks_detected_total" in prom