CIA/e-voting-system/backend/blockchain_client.py
Alexis Bruneteau d7ec538ed2 fix: Submit votes to ALL validators instead of single validator
Problem: Votes were only being submitted to one validator selected via
round-robin, then expected inter-validator broadcasting to propagate the
transaction. But inter-validator transaction broadcasting wasn't working
reliably.

Solution: Submit each vote to ALL healthy validators simultaneously.
This ensures every validator receives the transaction directly, making it
available for block creation regardless of inter-validator communication.

Benefits:
- No dependency on P2P transaction broadcasting
- All validators have same pending transaction pool
- Any validator can create blocks with all pending transactions
- More robust and simpler than trying to maintain P2P mesh

Implementation:
- Modified submit_vote() to loop through all healthy_validators
- Submit same JSON-RPC request to each validator
- Log results from each submission
- Require at least one successful submission

This is simpler and more reliable than the previous architecture.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 17:03:32 +01:00

520 lines
17 KiB
Python

"""
BlockchainClient for communicating with PoA validator nodes.
This client submits votes to the distributed PoA blockchain network
and queries the state of votes on the blockchain.
"""
import logging
import httpx
import json
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from enum import Enum
import asyncio
logger = logging.getLogger(__name__)
class ValidatorStatus(str, Enum):
"""Status of a validator node"""
HEALTHY = "healthy"
DEGRADED = "degraded"
UNREACHABLE = "unreachable"
@dataclass
class ValidatorNode:
"""Represents a PoA validator node"""
node_id: str
rpc_url: str # JSON-RPC endpoint
p2p_url: str # P2P networking endpoint
status: ValidatorStatus = ValidatorStatus.UNREACHABLE
@property
def health_check_url(self) -> str:
"""Health check endpoint"""
return f"{self.rpc_url}/health"
class BlockchainClient:
"""
Client for PoA blockchain network.
Features:
- Load balancing across multiple validators
- Health monitoring
- Automatic failover
- Vote submission and confirmation tracking
"""
# Default validator configuration
# Use Docker service names for internal container communication
# For external access (outside Docker), use localhost:PORT
DEFAULT_VALIDATORS = [
ValidatorNode(
node_id="validator-1",
rpc_url="http://validator-1:8001",
p2p_url="http://validator-1:30303"
),
ValidatorNode(
node_id="validator-2",
rpc_url="http://validator-2:8002",
p2p_url="http://validator-2:30304"
),
ValidatorNode(
node_id="validator-3",
rpc_url="http://validator-3:8003",
p2p_url="http://validator-3:30305"
),
]
def __init__(self, validators: Optional[List[ValidatorNode]] = None, timeout: float = 5.0):
"""
Initialize blockchain client.
Args:
validators: List of validator nodes (uses defaults if None)
timeout: HTTP request timeout in seconds
"""
self.validators = validators or self.DEFAULT_VALIDATORS
self.timeout = timeout
self.healthy_validators: List[ValidatorNode] = []
self._client: Optional[httpx.AsyncClient] = None
async def __aenter__(self):
"""Async context manager entry"""
logger.info("[BlockchainClient.__aenter__] Creating AsyncClient")
self._client = httpx.AsyncClient(timeout=self.timeout)
logger.info("[BlockchainClient.__aenter__] Refreshing validator status")
await self.refresh_validator_status()
logger.info(f"[BlockchainClient.__aenter__] Ready with {len(self.healthy_validators)} healthy validators")
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit"""
if self._client:
await self._client.aclose()
async def refresh_validator_status(self) -> None:
"""
Check health of all validators.
Updates the list of healthy validators for load balancing.
"""
if not self._client:
self._client = httpx.AsyncClient(timeout=self.timeout)
tasks = [self._check_validator_health(v) for v in self.validators]
await asyncio.gather(*tasks, return_exceptions=True)
self.healthy_validators = [
v for v in self.validators
if v.status == ValidatorStatus.HEALTHY
]
logger.info(
f"Validator health check: {len(self.healthy_validators)}/{len(self.validators)} healthy"
)
async def _check_validator_health(self, validator: ValidatorNode) -> None:
"""Check if a validator is healthy"""
try:
if not self._client:
return
response = await self._client.get(
validator.health_check_url,
timeout=self.timeout
)
if response.status_code == 200:
validator.status = ValidatorStatus.HEALTHY
logger.debug(f"{validator.node_id} is healthy")
else:
validator.status = ValidatorStatus.DEGRADED
logger.warning(f"{validator.node_id} returned status {response.status_code}")
except Exception as e:
validator.status = ValidatorStatus.UNREACHABLE
logger.warning(f"{validator.node_id} is unreachable: {e}")
def _get_healthy_validator(self) -> Optional[ValidatorNode]:
"""
Get a healthy validator for the next request.
Uses round-robin for load balancing.
"""
if not self.healthy_validators:
logger.error("No healthy validators available!")
return None
# Simple round-robin: return first healthy validator
# In production, implement proper round-robin state management
return self.healthy_validators[0]
async def submit_vote(
self,
voter_id: str,
election_id: int,
encrypted_vote: str,
transaction_id: Optional[str] = None,
ballot_hash: Optional[str] = None
) -> Dict[str, Any]:
"""
Submit a vote to ALL PoA validators simultaneously.
This ensures every validator receives the transaction directly,
guaranteeing it will be included in the next block.
Args:
voter_id: Voter identifier
election_id: Election ID
encrypted_vote: Encrypted vote data
transaction_id: Optional transaction ID (generated if not provided)
ballot_hash: Optional ballot hash for verification
Returns:
Transaction receipt with block hash and index
Raises:
Exception: If all validators are unreachable
"""
logger.info(f"[BlockchainClient.submit_vote] CALLED with voter_id={voter_id}, election_id={election_id}")
logger.info(f"[BlockchainClient.submit_vote] healthy_validators count: {len(self.healthy_validators)}")
if not self.healthy_validators:
logger.error("[BlockchainClient.submit_vote] No healthy validators available!")
raise Exception("No healthy validators available")
# Generate transaction ID if not provided
if not transaction_id:
import uuid
transaction_id = f"tx-{uuid.uuid4().hex[:12]}"
# Generate ballot hash if not provided
if not ballot_hash:
import hashlib
ballot_hash = hashlib.sha256(f"{voter_id}{election_id}{encrypted_vote}".encode()).hexdigest()
import time
# Create transaction data as JSON
tx_data = {
"voter_id": str(voter_id),
"election_id": int(election_id),
"encrypted_vote": str(encrypted_vote),
"ballot_hash": str(ballot_hash),
"timestamp": int(time.time())
}
# Encode transaction data as hex string with 0x prefix
import json
tx_json = json.dumps(tx_data)
data_hex = "0x" + tx_json.encode().hex()
# Prepare JSON-RPC request
rpc_request = {
"jsonrpc": "2.0",
"method": "eth_sendTransaction",
"params": [{
"from": voter_id,
"to": f"election-{election_id}",
"data": data_hex,
"gas": "0x5208"
}],
"id": transaction_id
}
# Submit to ALL healthy validators simultaneously
logger.info(f"[BlockchainClient.submit_vote] Submitting to {len(self.healthy_validators)} validators")
results = {}
if not self._client:
logger.error("[BlockchainClient.submit_vote] AsyncClient not initialized!")
raise Exception("AsyncClient not initialized")
for validator in self.healthy_validators:
try:
logger.info(f"[BlockchainClient.submit_vote] Submitting to {validator.node_id} ({validator.rpc_url}/rpc)")
response = await self._client.post(
f"{validator.rpc_url}/rpc",
json=rpc_request,
timeout=self.timeout
)
logger.info(f"[BlockchainClient.submit_vote] Response from {validator.node_id}: status={response.status_code}")
response.raise_for_status()
result = response.json()
# Check for JSON-RPC errors
if "error" in result:
logger.error(f"RPC error from {validator.node_id}: {result['error']}")
results[validator.node_id] = f"RPC error: {result['error']}"
else:
logger.info(f"✓ Vote accepted by {validator.node_id}: {result.get('result')}")
results[validator.node_id] = result.get("result")
except Exception as e:
logger.warning(f"Failed to submit to {validator.node_id}: {e}")
results[validator.node_id] = str(e)
# Check if at least one validator accepted the vote
successful = [v for v in results.values() if not str(v).startswith(("RPC error", "Failed"))]
if successful:
logger.info(f"✓ Vote submitted successfully to {len(successful)} validators: {transaction_id}")
return {
"transaction_id": transaction_id,
"block_hash": successful[0] if successful else None,
"validator": self.healthy_validators[0].node_id,
"status": "pending"
}
else:
logger.error(f"Failed to submit vote to any validator")
raise Exception(f"All validator submissions failed: {results}")
async def get_transaction_receipt(
self,
transaction_id: str,
election_id: int
) -> Optional[Dict[str, Any]]:
"""
Get the receipt for a submitted vote.
Args:
transaction_id: Transaction ID returned from submit_vote
election_id: Election ID
Returns:
Transaction receipt with confirmation status and block info
"""
validator = self._get_healthy_validator()
if not validator:
return None
rpc_request = {
"jsonrpc": "2.0",
"method": "eth_getTransactionReceipt",
"params": [transaction_id],
"id": transaction_id
}
try:
if not self._client:
raise Exception("AsyncClient not initialized")
response = await self._client.post(
f"{validator.rpc_url}/rpc",
json=rpc_request,
timeout=self.timeout
)
result = response.json()
if "error" in result:
logger.warning(f"RPC error: {result['error']}")
return None
receipt = result.get("result")
if receipt:
logger.debug(f"✓ Got receipt for {transaction_id}: block {receipt.get('blockNumber')}")
return receipt
except Exception as e:
logger.warning(f"Failed to get receipt for {transaction_id}: {e}")
return None
async def get_vote_confirmation_status(
self,
transaction_id: str,
election_id: int
) -> Dict[str, Any]:
"""
Check if a vote has been confirmed on the blockchain.
Args:
transaction_id: Transaction ID
election_id: Election ID
Returns:
Status information including block number and finality
"""
receipt = await self.get_transaction_receipt(transaction_id, election_id)
if receipt is None:
return {
"status": "pending",
"confirmed": False,
"transaction_id": transaction_id
}
return {
"status": "confirmed",
"confirmed": True,
"transaction_id": transaction_id,
"block_number": receipt.get("blockNumber"),
"block_hash": receipt.get("blockHash"),
"gas_used": receipt.get("gasUsed")
}
async def get_blockchain_state(self, election_id: int) -> Optional[Dict[str, Any]]:
"""
Get the current state of the blockchain for an election.
Args:
election_id: Election ID
Returns:
Blockchain state with block count and verification status
"""
validator = self._get_healthy_validator()
if not validator:
return None
try:
if not self._client:
raise Exception("AsyncClient not initialized")
# Query blockchain info endpoint on validator
response = await self._client.get(
f"{validator.rpc_url}/blockchain",
params={"election_id": election_id},
timeout=self.timeout
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.warning(f"Failed to get blockchain state: {e}")
return None
async def verify_blockchain_integrity(self, election_id: int) -> bool:
"""
Verify that the blockchain for an election is valid and unmodified.
Args:
election_id: Election ID
Returns:
True if blockchain is valid, False otherwise
"""
state = await self.get_blockchain_state(election_id)
if state is None:
return False
verification = state.get("verification", {})
is_valid = verification.get("chain_valid", False)
if is_valid:
logger.info(f"✓ Blockchain for election {election_id} is valid")
else:
logger.error(f"✗ Blockchain for election {election_id} is INVALID")
return is_valid
async def get_election_results(self, election_id: int) -> Optional[Dict[str, Any]]:
"""
Get the current vote counts for an election from the blockchain.
Args:
election_id: Election ID
Returns:
Vote counts by candidate and verification status
"""
validator = self._get_healthy_validator()
if not validator:
return None
try:
if not self._client:
raise Exception("AsyncClient not initialized")
# Query results endpoint on validator
response = await self._client.get(
f"{validator.rpc_url}/results",
params={"election_id": election_id},
timeout=self.timeout
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.warning(f"Failed to get election results: {e}")
return None
async def wait_for_confirmation(
self,
transaction_id: str,
election_id: int,
max_wait_seconds: int = 30,
poll_interval_seconds: float = 1.0
) -> bool:
"""
Wait for a vote to be confirmed on the blockchain.
Args:
transaction_id: Transaction ID
election_id: Election ID
max_wait_seconds: Maximum time to wait in seconds
poll_interval_seconds: Time between status checks
Returns:
True if vote was confirmed, False if timeout
"""
import time
start_time = time.time()
while time.time() - start_time < max_wait_seconds:
status = await self.get_vote_confirmation_status(transaction_id, election_id)
if status.get("confirmed"):
logger.info(f"✓ Vote confirmed: {transaction_id}")
return True
logger.debug(f"Waiting for confirmation... ({status['status']})")
await asyncio.sleep(poll_interval_seconds)
logger.warning(f"Confirmation timeout for {transaction_id}")
return False
# Singleton instance for use throughout the backend
_blockchain_client: Optional[BlockchainClient] = None
async def get_blockchain_client() -> BlockchainClient:
"""
Get or create the global blockchain client instance.
Returns:
BlockchainClient instance
"""
global _blockchain_client
if _blockchain_client is None:
_blockchain_client = BlockchainClient()
await _blockchain_client.refresh_validator_status()
return _blockchain_client
def get_blockchain_client_sync() -> BlockchainClient:
"""
Get the blockchain client (for sync contexts).
Note: This returns the client without initializing it.
Use with caution in async contexts.
Returns:
BlockchainClient instance
"""
global _blockchain_client
if _blockchain_client is None:
_blockchain_client = BlockchainClient()
return _blockchain_client