Merge aitbc1/blockchain-production into main: Enhanced chain sync, PoA transaction processing, and production genesis tooling
Some checks failed
AITBC CI/CD Pipeline / lint-and-test (3.11) (pull_request) Has been cancelled
AITBC CI/CD Pipeline / lint-and-test (3.12) (pull_request) Has been cancelled
AITBC CI/CD Pipeline / lint-and-test (3.13) (pull_request) Has been cancelled
AITBC CLI Level 1 Commands Test / test-cli-level1 (3.11) (pull_request) Has been cancelled
AITBC CLI Level 1 Commands Test / test-cli-level1 (3.12) (pull_request) Has been cancelled
AITBC CLI Level 1 Commands Test / test-cli-level1 (3.13) (pull_request) Has been cancelled
Security Scanning / Bandit Security Scan (apps/coordinator-api/src) (pull_request) Has been cancelled
Security Scanning / Bandit Security Scan (cli/aitbc_cli) (pull_request) Has been cancelled
Security Scanning / Bandit Security Scan (packages/py/aitbc-core/src) (pull_request) Has been cancelled
Security Scanning / Bandit Security Scan (packages/py/aitbc-crypto/src) (pull_request) Has been cancelled
Security Scanning / Bandit Security Scan (packages/py/aitbc-sdk/src) (pull_request) Has been cancelled
Security Scanning / Bandit Security Scan (tests) (pull_request) Has been cancelled
Security Scanning / CodeQL Security Analysis (javascript) (pull_request) Has been cancelled
Security Scanning / CodeQL Security Analysis (python) (pull_request) Has been cancelled
Security Scanning / Dependency Security Scan (pull_request) Has been cancelled
Security Scanning / Container Security Scan (pull_request) Has been cancelled
Security Scanning / OSSF Scorecard (pull_request) Has been cancelled
AITBC CI/CD Pipeline / test-cli (pull_request) Has been cancelled
AITBC CI/CD Pipeline / test-services (pull_request) Has been cancelled
AITBC CI/CD Pipeline / test-production-services (pull_request) Has been cancelled
AITBC CI/CD Pipeline / security-scan (pull_request) Has been cancelled
AITBC CI/CD Pipeline / build (pull_request) Has been cancelled
AITBC CI/CD Pipeline / deploy-staging (pull_request) Has been cancelled
AITBC CI/CD Pipeline / deploy-production (pull_request) Has been cancelled
AITBC CI/CD Pipeline / performance-test (pull_request) Has been cancelled
AITBC CI/CD Pipeline / docs (pull_request) Has been cancelled
AITBC CI/CD Pipeline / release (pull_request) Has been cancelled
AITBC CI/CD Pipeline / notify (pull_request) Has been cancelled
AITBC CLI Level 1 Commands Test / test-summary (pull_request) Has been cancelled
Security Scanning / Security Summary Report (pull_request) Has been cancelled

This commit is contained in:
2026-03-20 10:50:43 +01:00
18 changed files with 2263 additions and 275 deletions

View File

@@ -1,63 +1,58 @@
# AITBC Environment Configuration # AITBC Central Environment Example
# SECURITY NOTICE: Use service-specific environment files # SECURITY NOTICE: Use a secrets manager for production. Do not commit real secrets.
# # Run: python config/security/environment-audit.py --format text
# For development, copy from:
# config/environments/development/coordinator.env
# config/environments/development/wallet-daemon.env
#
# For production, use AWS Secrets Manager and Kubernetes secrets
# Templates available in config/environments/production/
# ============================================================================= # =========================
# BASIC CONFIGURATION ONLY # Blockchain core
# ============================================================================= # =========================
# Application Environment chain_id=ait-mainnet
APP_ENV=development supported_chains=ait-mainnet
DEBUG=false rpc_bind_host=0.0.0.0
LOG_LEVEL=INFO rpc_bind_port=8006
p2p_bind_host=0.0.0.0
p2p_bind_port=8005
proposer_id=aitbc1genesis
proposer_key=changeme_hex_private_key
keystore_path=/opt/aitbc/keystore
keystore_password_file=/opt/aitbc/keystore/.password
gossip_backend=broadcast
gossip_broadcast_url=redis://127.0.0.1:6379
db_path=/opt/aitbc/apps/blockchain-node/data/ait-mainnet/chain.db
mint_per_unit=0
coordinator_ratio=0.05
block_time_seconds=60
enable_block_production=true
# ============================================================================= # =========================
# SECURITY REQUIREMENTS # Coordinator API
# ============================================================================= # =========================
# IMPORTANT: Do NOT store actual secrets in this file APP_ENV=production
# Use AWS Secrets Manager for production APP_HOST=127.0.0.1
# Generate secure keys with: openssl rand -hex 32 APP_PORT=8011
DATABASE__URL=sqlite:///./data/coordinator.db
BLOCKCHAIN_RPC_URL=http://127.0.0.1:8026
ALLOW_ORIGINS=["http://localhost:8011","http://localhost:8000","http://8026"]
JOB_TTL_SECONDS=900
HEARTBEAT_INTERVAL_SECONDS=10
HEARTBEAT_TIMEOUT_SECONDS=30
RATE_LIMIT_REQUESTS=60
RATE_LIMIT_WINDOW_SECONDS=60
CLIENT_API_KEYS=["client_prod_key_use_real_value"]
MINER_API_KEYS=["miner_prod_key_use_real_value"]
ADMIN_API_KEYS=["admin_prod_key_use_real_value"]
HMAC_SECRET=change_this_to_a_32_byte_random_secret
JWT_SECRET=change_this_to_another_32_byte_random_secret
# ============================================================================= # =========================
# SERVICE CONFIGURATION # Marketplace Web
# ============================================================================= # =========================
# Choose your service configuration: VITE_MARKETPLACE_DATA_MODE=live
# 1. Copy service-specific .env file from config/environments/ VITE_MARKETPLACE_API=/api
# 2. Fill in actual values (NEVER commit secrets) VITE_MARKETPLACE_ENABLE_BIDS=true
# 3. Run: python config/security/environment-audit.py VITE_MARKETPLACE_REQUIRE_AUTH=false
# ============================================================================= # =========================
# DEVELOPMENT QUICK START # Notes
# ============================================================================= # =========================
# For quick development setup: # For production: move secrets to a secrets manager and reference via secretRef
# cp config/environments/development/coordinator.env .env # Validate config: python config/security/environment-audit.py --format text
# cp config/environments/development/wallet-daemon.env .env.wallet
#
# Then edit the copied files with your values
# =============================================================================
# PRODUCTION DEPLOYMENT
# =============================================================================
# For production deployment:
# 1. Use AWS Secrets Manager for all sensitive values
# 2. Reference secrets as: secretRef:secret-name:key
# 3. Run security audit before deployment
# 4. Use templates in config/environments/production/
# =============================================================================
# SECURITY VALIDATION
# =============================================================================
# Validate your configuration:
# python config/security/environment-audit.py --format text
# =============================================================================
# FOR MORE INFORMATION
# =============================================================================
# See: config/security/secret-validation.yaml
# See: config/security/environment-audit.py
# See: config/environments/ directory

View File

@@ -32,6 +32,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
# Bypass rate limiting for localhost (sync/health internal traffic)
if client_ip in {"127.0.0.1", "::1"}:
return await call_next(request)
now = time.time() now = time.time()
# Clean old entries # Clean old entries
self._requests[client_ip] = [ self._requests[client_ip] = [
@@ -109,7 +112,8 @@ def create_app() -> FastAPI:
# Middleware (applied in reverse order) # Middleware (applied in reverse order)
app.add_middleware(RequestLoggingMiddleware) app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(RateLimitMiddleware, max_requests=200, window_seconds=60) # Allow higher RPC throughput (sync + node traffic)
app.add_middleware(RateLimitMiddleware, max_requests=5000, window_seconds=60)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[

View File

@@ -13,12 +13,20 @@ from typing import Dict, Any, Optional, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ChainSyncService: class ChainSyncService:
def __init__(self, redis_url: str, node_id: str, rpc_port: int = 8006): def __init__(self, redis_url: str, node_id: str, rpc_port: int = 8006, leader_host: str = None,
source_host: str = "127.0.0.1", source_port: int = None,
import_host: str = "127.0.0.1", import_port: int = None):
self.redis_url = redis_url self.redis_url = redis_url
self.node_id = node_id self.node_id = node_id
self.rpc_port = rpc_port self.rpc_port = rpc_port # kept for backward compat (local poll if source_port None)
self.leader_host = leader_host # Host of the leader node (legacy)
self.source_host = source_host
self.source_port = source_port or rpc_port
self.import_host = import_host
self.import_port = import_port or rpc_port
self._stop_event = asyncio.Event() self._stop_event = asyncio.Event()
self._redis = None self._redis = None
self._receiver_ready = asyncio.Event()
async def start(self): async def start(self):
"""Start chain synchronization service""" """Start chain synchronization service"""
@@ -34,10 +42,11 @@ class ChainSyncService:
return return
# Start block broadcasting task # Start block broadcasting task
broadcast_task = asyncio.create_task(self._broadcast_blocks())
# Start block receiving task # Start block receiving task
receive_task = asyncio.create_task(self._receive_blocks()) receive_task = asyncio.create_task(self._receive_blocks())
# Wait until receiver subscribed so we don't drop the initial burst
await self._receiver_ready.wait()
broadcast_task = asyncio.create_task(self._broadcast_blocks())
try: try:
await self._stop_event.wait() await self._stop_event.wait()
@@ -59,16 +68,22 @@ class ChainSyncService:
import aiohttp import aiohttp
last_broadcast_height = 0 last_broadcast_height = 0
retry_count = 0
max_retries = 5
base_delay = 2
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
# Get current head from local RPC # Get current head from local RPC
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(f"http://127.0.0.1:{self.rpc_port}/rpc/head") as resp: async with session.get(f"http://{self.source_host}:{self.source_port}/rpc/head") as resp:
if resp.status == 200: if resp.status == 200:
head_data = await resp.json() head_data = await resp.json()
current_height = head_data.get('height', 0) current_height = head_data.get('height', 0)
# Reset retry count on successful connection
retry_count = 0
# Broadcast new blocks # Broadcast new blocks
if current_height > last_broadcast_height: if current_height > last_broadcast_height:
for height in range(last_broadcast_height + 1, current_height + 1): for height in range(last_broadcast_height + 1, current_height + 1):
@@ -78,11 +93,30 @@ class ChainSyncService:
last_broadcast_height = current_height last_broadcast_height = current_height
logger.info(f"Broadcasted blocks up to height {current_height}") logger.info(f"Broadcasted blocks up to height {current_height}")
elif resp.status == 429:
raise Exception("rate_limit")
else:
raise Exception(f"RPC returned status {resp.status}")
except Exception as e: except Exception as e:
logger.error(f"Error in block broadcast: {e}") retry_count += 1
# If rate-limited, wait longer before retrying
if str(e) == "rate_limit":
delay = base_delay * 30
logger.warning(f"RPC rate limited, retrying in {delay}s")
await asyncio.sleep(delay)
continue
if retry_count <= max_retries:
delay = base_delay * (2 ** (retry_count - 1)) # Exponential backoff
logger.warning(f"RPC connection failed (attempt {retry_count}/{max_retries}), retrying in {delay}s: {e}")
await asyncio.sleep(delay)
continue
else:
logger.error(f"RPC connection failed after {max_retries} attempts, waiting {base_delay * 10}s: {e}")
await asyncio.sleep(base_delay * 10)
retry_count = 0 # Reset retry count after long wait
await asyncio.sleep(2) # Check every 2 seconds await asyncio.sleep(base_delay) # Check every 2 seconds when connected
async def _receive_blocks(self): async def _receive_blocks(self):
"""Receive blocks from other nodes via Redis""" """Receive blocks from other nodes via Redis"""
@@ -91,6 +125,7 @@ class ChainSyncService:
pubsub = self._redis.pubsub() pubsub = self._redis.pubsub()
await pubsub.subscribe("blocks") await pubsub.subscribe("blocks")
self._receiver_ready.set()
logger.info("Subscribed to block broadcasts") logger.info("Subscribed to block broadcasts")
@@ -108,11 +143,12 @@ class ChainSyncService:
async def _get_block_by_height(self, height: int, session) -> Optional[Dict[str, Any]]: async def _get_block_by_height(self, height: int, session) -> Optional[Dict[str, Any]]:
"""Get block data by height from local RPC""" """Get block data by height from local RPC"""
try: try:
async with session.get(f"http://127.0.0.1:{self.rpc_port}/rpc/blocks?start={height}&end={height}") as resp: async with session.get(f"http://{self.source_host}:{self.source_port}/rpc/blocks-range?start={height}&end={height}") as resp:
if resp.status == 200: if resp.status == 200:
blocks_data = await resp.json() blocks_data = await resp.json()
blocks = blocks_data.get('blocks', []) blocks = blocks_data.get('blocks', [])
return blocks[0] if blocks else None block = blocks[0] if blocks else None
return block
except Exception as e: except Exception as e:
logger.error(f"Error getting block {height}: {e}") logger.error(f"Error getting block {height}: {e}")
return None return None
@@ -137,26 +173,68 @@ class ChainSyncService:
if block_data.get('proposer') == self.node_id: if block_data.get('proposer') == self.node_id:
return return
async with aiohttp.ClientSession() as session: # Determine target host - if we're a follower, import to leader, else import locally
async with session.post( target_host = self.import_host
f"http://127.0.0.1:{self.rpc_port}/rpc/importBlock", target_port = self.import_port
json=block_data
) as resp: # Retry logic for import
if resp.status == 200: max_retries = 3
result = await resp.json() base_delay = 1
if result.get('accepted'):
logger.info(f"Imported block {block_data.get('height')} from {block_data.get('proposer')}") for attempt in range(max_retries):
else: try:
logger.debug(f"Rejected block {block_data.get('height')}: {result.get('reason')}") async with aiohttp.ClientSession() as session:
async with session.post(
f"http://{target_host}:{target_port}/rpc/importBlock",
json=block_data
) as resp:
if resp.status == 200:
result = await resp.json()
if result.get('accepted'):
logger.info(f"Imported block {block_data.get('height')} from {block_data.get('proposer')}")
else:
logger.debug(f"Rejected block {block_data.get('height')}: {result.get('reason')}")
return
else:
try:
body = await resp.text()
except Exception:
body = "<no body>"
raise Exception(f"HTTP {resp.status}: {body}")
except Exception as e:
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
logger.warning(f"Import failed (attempt {attempt + 1}/{max_retries}), retrying in {delay}s: {e}")
await asyncio.sleep(delay)
else: else:
logger.warning(f"Failed to import block: {resp.status}") logger.error(f"Failed to import block {block_data.get('height')} after {max_retries} attempts: {e}")
return
except Exception as e: except Exception as e:
logger.error(f"Error importing block: {e}") logger.error(f"Error importing block: {e}")
async def run_chain_sync(redis_url: str, node_id: str, rpc_port: int = 8006): async def run_chain_sync(
redis_url: str,
node_id: str,
rpc_port: int = 8006,
leader_host: str = None,
source_host: str = "127.0.0.1",
source_port: int = None,
import_host: str = "127.0.0.1",
import_port: int = None,
):
"""Run chain synchronization service""" """Run chain synchronization service"""
service = ChainSyncService(redis_url, node_id, rpc_port) service = ChainSyncService(
redis_url=redis_url,
node_id=node_id,
rpc_port=rpc_port,
leader_host=leader_host,
source_host=source_host,
source_port=source_port,
import_host=import_host,
import_port=import_port,
)
await service.start() await service.start()
def main(): def main():
@@ -166,13 +244,27 @@ def main():
parser.add_argument("--redis", default="redis://localhost:6379", help="Redis URL") parser.add_argument("--redis", default="redis://localhost:6379", help="Redis URL")
parser.add_argument("--node-id", required=True, help="Node identifier") parser.add_argument("--node-id", required=True, help="Node identifier")
parser.add_argument("--rpc-port", type=int, default=8006, help="RPC port") parser.add_argument("--rpc-port", type=int, default=8006, help="RPC port")
parser.add_argument("--leader-host", help="Leader node host (for followers)")
parser.add_argument("--source-host", default="127.0.0.1", help="Host to poll for head/blocks")
parser.add_argument("--source-port", type=int, help="Port to poll for head/blocks")
parser.add_argument("--import-host", default="127.0.0.1", help="Host to import blocks into")
parser.add_argument("--import-port", type=int, help="Port to import blocks into")
args = parser.parse_args() args = parser.parse_args()
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
try: try:
asyncio.run(run_chain_sync(args.redis, args.node_id, args.rpc_port)) asyncio.run(run_chain_sync(
args.redis,
args.node_id,
args.rpc_port,
args.leader_host,
args.source_host,
args.source_port,
args.import_host,
args.import_port,
))
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Chain sync service stopped by user") logger.info("Chain sync service stopped by user")

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Combined blockchain node and P2P service launcher
Runs both the main blockchain node and P2P placeholder service
"""
import asyncio
import logging
import os
import signal
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from aitbc_chain.main import BlockchainNode, _run as run_node
from aitbc_chain.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class CombinedService:
def __init__(self):
self._stop_event = asyncio.Event()
self._tasks = []
self._loop = None
def set_stop_event(self):
"""Set the stop event to trigger shutdown"""
if self._stop_event and not self._stop_event.is_set():
self._stop_event.set()
async def start(self):
"""Start both blockchain node and P2P server"""
self._loop = asyncio.get_running_loop()
logger.info("Starting combined blockchain service")
# Start blockchain node in background
node_task = asyncio.create_task(run_node())
self._tasks.append(node_task)
logger.info(f"Combined service started - Node on mainnet")
try:
await self._stop_event.wait()
finally:
await self.stop()
async def stop(self):
"""Stop all services"""
logger.info("Stopping combined blockchain service")
# Cancel all tasks
for task in self._tasks:
task.cancel()
# Wait for tasks to complete
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
logger.info("Combined service stopped")
# Global service instance for signal handler
_service_instance = None
def signal_handler(signum, frame):
"""Handle shutdown signals"""
logger.info(f"Received signal {signum}, initiating shutdown")
global _service_instance
if _service_instance:
_service_instance.set_stop_event()
async def main():
"""Main entry point"""
global _service_instance
service = CombinedService()
_service_instance = service
# Set up signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
try:
await service.start()
except KeyboardInterrupt:
logger.info("Received keyboard interrupt")
finally:
await service.stop()
_service_instance = None
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -36,6 +36,9 @@ class ChainSettings(BaseSettings):
block_time_seconds: int = 2 block_time_seconds: int = 2
# Block production toggle (set false on followers)
enable_block_production: bool = True
# Block production limits # Block production limits
max_block_size_bytes: int = 1_000_000 # 1 MB max_block_size_bytes: int = 1_000_000 # 1 MB
max_txs_per_block: int = 500 max_txs_per_block: int = 500

View File

@@ -120,11 +120,10 @@ class PoAProposer:
return return
async def _propose_block(self) -> None: async def _propose_block(self) -> None:
# Check internal mempool - but produce empty blocks to keep chain moving # Check internal mempool and include transactions
from ..mempool import get_mempool from ..mempool import get_mempool
mempool_size = get_mempool().size(self._config.chain_id) from ..models import Transaction, Account
if mempool_size == 0: mempool = get_mempool()
self._logger.debug("No transactions in mempool, producing empty block")
with self._session_factory() as session: with self._session_factory() as session:
head = session.exec(select(Block).where(Block.chain_id == self._config.chain_id).order_by(Block.height.desc()).limit(1)).first() head = session.exec(select(Block).where(Block.chain_id == self._config.chain_id).order_by(Block.height.desc()).limit(1)).first()
@@ -137,7 +136,71 @@ class PoAProposer:
interval_seconds = (datetime.utcnow() - head.timestamp).total_seconds() interval_seconds = (datetime.utcnow() - head.timestamp).total_seconds()
timestamp = datetime.utcnow() timestamp = datetime.utcnow()
block_hash = self._compute_block_hash(next_height, parent_hash, timestamp)
# Pull transactions from mempool
max_txs = self._config.max_txs_per_block
max_bytes = self._config.max_block_size_bytes
pending_txs = mempool.drain(max_txs, max_bytes, self._config.chain_id)
self._logger.info(f"[PROPOSE] drained {len(pending_txs)} txs from mempool, chain={self._config.chain_id}")
# Process transactions and update balances
processed_txs = []
for tx in pending_txs:
try:
# Parse transaction data
tx_data = tx.content
sender = tx_data.get("sender")
recipient = tx_data.get("payload", {}).get("to")
value = tx_data.get("payload", {}).get("value", 0)
fee = tx_data.get("fee", 0)
if not sender or not recipient:
continue
# Get sender account
sender_account = session.get(Account, (self._config.chain_id, sender))
if not sender_account:
continue
# Check sufficient balance
total_cost = value + fee
if sender_account.balance < total_cost:
continue
# Get or create recipient account
recipient_account = session.get(Account, (self._config.chain_id, recipient))
if not recipient_account:
recipient_account = Account(chain_id=self._config.chain_id, address=recipient, balance=0, nonce=0)
session.add(recipient_account)
session.flush()
# Update balances
sender_account.balance -= total_cost
sender_account.nonce += 1
recipient_account.balance += value
# Create transaction record
transaction = Transaction(
chain_id=self._config.chain_id,
tx_hash=tx.tx_hash,
sender=sender,
recipient=recipient,
value=value,
fee=fee,
nonce=sender_account.nonce - 1,
timestamp=timestamp,
block_height=next_height,
status="confirmed"
)
session.add(transaction)
processed_txs.append(tx)
except Exception as e:
self._logger.warning(f"Failed to process transaction {tx.tx_hash}: {e}")
continue
# Compute block hash with transaction data
block_hash = self._compute_block_hash(next_height, parent_hash, timestamp, processed_txs)
block = Block( block = Block(
chain_id=self._config.chain_id, chain_id=self._config.chain_id,
@@ -146,7 +209,7 @@ class PoAProposer:
parent_hash=parent_hash, parent_hash=parent_hash,
proposer=self._config.proposer_id, proposer=self._config.proposer_id,
timestamp=timestamp, timestamp=timestamp,
tx_count=0, tx_count=len(processed_txs),
state_root=None, state_root=None,
) )
session.add(block) session.add(block)
@@ -259,6 +322,11 @@ class PoAProposer:
with self._session_factory() as session: with self._session_factory() as session:
return session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first() return session.exec(select(Block).order_by(Block.height.desc()).limit(1)).first()
def _compute_block_hash(self, height: int, parent_hash: str, timestamp: datetime) -> str: def _compute_block_hash(self, height: int, parent_hash: str, timestamp: datetime, transactions: list = None) -> str:
payload = f"{self._config.chain_id}|{height}|{parent_hash}|{timestamp.isoformat()}".encode() # Include transaction hashes in block hash computation
tx_hashes = []
if transactions:
tx_hashes = [tx.tx_hash for tx in transactions]
payload = f"{self._config.chain_id}|{height}|{parent_hash}|{timestamp.isoformat()}|{'|'.join(sorted(tx_hashes))}".encode()
return "0x" + hashlib.sha256(payload).hexdigest() return "0x" + hashlib.sha256(payload).hexdigest()

View File

@@ -148,7 +148,11 @@ class BlockchainNode:
max_size=settings.mempool_max_size, max_size=settings.mempool_max_size,
min_fee=settings.min_fee, min_fee=settings.min_fee,
) )
self._start_proposers() # Start proposers only if enabled (followers set enable_block_production=False)
if getattr(settings, "enable_block_production", True):
self._start_proposers()
else:
logger.info("Block production disabled on this node", extra={"proposer_id": settings.proposer_id})
await self._setup_gossip_subscribers() await self._setup_gossip_subscribers()
try: try:
await self._stop_event.wait() await self._stop_event.wait()

View File

@@ -110,6 +110,7 @@ async def get_block(height: int) -> Dict[str, Any]:
"height": block.height, "height": block.height,
"hash": block.hash, "hash": block.hash,
"parent_hash": block.parent_hash, "parent_hash": block.parent_hash,
"proposer": block.proposer,
"timestamp": block.timestamp.isoformat(), "timestamp": block.timestamp.isoformat(),
"tx_count": block.tx_count, "tx_count": block.tx_count,
"state_root": block.state_root, "state_root": block.state_root,
@@ -154,6 +155,7 @@ async def get_blocks_range(start: int, end: int) -> Dict[str, Any]:
"height": block.height, "height": block.height,
"hash": block.hash, "hash": block.hash,
"parent_hash": block.parent_hash, "parent_hash": block.parent_hash,
"proposer": block.proposer,
"timestamp": block.timestamp.isoformat(), "timestamp": block.timestamp.isoformat(),
"tx_count": block.tx_count, "tx_count": block.tx_count,
"state_root": block.state_root, "state_root": block.state_root,
@@ -312,6 +314,7 @@ async def get_receipts(limit: int = 20, offset: int = 0) -> Dict[str, Any]:
@router.get("/getBalance/{address}", summary="Get account balance") @router.get("/getBalance/{address}", summary="Get account balance")
async def get_balance(address: str, chain_id: str = None) -> Dict[str, Any]: async def get_balance(address: str, chain_id: str = None) -> Dict[str, Any]:
chain_id = get_chain_id(chain_id)
metrics_registry.increment("rpc_get_balance_total") metrics_registry.increment("rpc_get_balance_total")
start = time.perf_counter() start = time.perf_counter()
with session_scope() as session: with session_scope() as session:
@@ -652,34 +655,37 @@ async def get_blockchain_info(chain_id: str = None) -> Dict[str, Any]:
async def get_token_supply(chain_id: str = None) -> Dict[str, Any]: async def get_token_supply(chain_id: str = None) -> Dict[str, Any]:
"""Get token supply information""" """Get token supply information"""
from ..config import settings as cfg from ..config import settings as cfg
from ..models import Account
# Use default chain_id from settings if not provided chain_id = get_chain_id(chain_id)
if chain_id is None:
chain_id = cfg.chain_id
metrics_registry.increment("rpc_supply_total") metrics_registry.increment("rpc_supply_total")
start = time.perf_counter() start = time.perf_counter()
with session_scope() as session: with session_scope() as session:
# Production implementation - no faucet in mainnet # Calculate actual values from database
accounts = session.exec(select(Account).where(Account.chain_id == chain_id)).all()
total_balance = sum(account.balance for account in accounts)
total_accounts = len(accounts)
# Production implementation - calculate real circulating supply
if chain_id == "ait-mainnet": if chain_id == "ait-mainnet":
response = { response = {
"chain_id": chain_id, "chain_id": chain_id,
"total_supply": 1000000000, # 1 billion from genesis "total_supply": 1000000000, # 1 billion from genesis
"circulating_supply": 0, # No transactions yet "circulating_supply": total_balance, # Actual tokens in circulation
"mint_per_unit": cfg.mint_per_unit, "mint_per_unit": cfg.mint_per_unit,
"total_accounts": 0 "total_accounts": total_accounts # Actual account count
} }
else: else:
# Devnet with faucet # Devnet with faucet - use actual calculations
response = { response = {
"chain_id": chain_id, "chain_id": chain_id,
"total_supply": 1000000000, # 1 billion from genesis "total_supply": 1000000000, # 1 billion from genesis
"circulating_supply": 0, # No transactions yet "circulating_supply": total_balance, # Actual tokens in circulation
"faucet_balance": 1000000000, # All tokens in faucet "faucet_balance": 1000000000, # All tokens in faucet
"faucet_address": "ait1faucet000000000000000000000000000000000", "faucet_address": "ait1faucet000000000000000000000000000000000",
"mint_per_unit": cfg.mint_per_unit, "mint_per_unit": cfg.mint_per_unit,
"total_accounts": 0 "total_accounts": total_accounts # Actual account count
} }
metrics_registry.observe("rpc_supply_duration_seconds", time.perf_counter() - start) metrics_registry.observe("rpc_supply_duration_seconds", time.perf_counter() - start)

View File

@@ -14,7 +14,7 @@ from sqlmodel import Session, select
from .config import settings from .config import settings
from .logger import get_logger from .logger import get_logger
from .metrics import metrics_registry from .metrics import metrics_registry
from .models import Block, Transaction from .models import Block, Transaction, Account
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -202,15 +202,45 @@ class ChainSync:
) )
session.add(block) session.add(block)
# Import transactions if provided # Import transactions if provided and apply state changes
if transactions: if transactions:
for tx_data in transactions: for tx_data in transactions:
sender_addr = tx_data.get("sender", "")
payload = tx_data.get("payload", {}) or {}
recipient_addr = payload.get("to") or tx_data.get("recipient", "")
value = int(payload.get("value", 0) or 0)
fee = int(tx_data.get("fee", 0) or 0)
tx_hash = tx_data.get("tx_hash", "")
# Upsert sender/recipient accounts
sender_acct = session.get(Account, (self._chain_id, sender_addr))
if sender_acct is None:
sender_acct = Account(chain_id=self._chain_id, address=sender_addr, balance=0, nonce=0)
session.add(sender_acct)
session.flush()
recipient_acct = session.get(Account, (self._chain_id, recipient_addr))
if recipient_acct is None:
recipient_acct = Account(chain_id=self._chain_id, address=recipient_addr, balance=0, nonce=0)
session.add(recipient_acct)
session.flush()
# Apply balances/nonce; assume block validity already verified on producer
total_cost = value + fee
sender_acct.balance -= total_cost
tx_nonce = tx_data.get("nonce")
if tx_nonce is not None:
sender_acct.nonce = max(sender_acct.nonce, int(tx_nonce) + 1)
else:
sender_acct.nonce += 1
recipient_acct.balance += value
tx = Transaction( tx = Transaction(
chain_id=self._chain_id, chain_id=self._chain_id,
tx_hash=tx_data.get("tx_hash", ""), tx_hash=tx_hash,
block_height=block_data["height"], block_height=block_data["height"],
sender=tx_data.get("sender", ""), sender=sender_addr,
recipient=tx_data.get("recipient", ""), recipient=recipient_addr,
payload=tx_data, payload=tx_data,
) )
session.add(tx) session.add(tx)

View File

@@ -9,12 +9,57 @@ from ..core.genesis_generator import GenesisGenerator, GenesisValidationError
from ..core.config import MultiChainConfig, load_multichain_config from ..core.config import MultiChainConfig, load_multichain_config
from ..models.chain import GenesisConfig from ..models.chain import GenesisConfig
from ..utils import output, error, success from ..utils import output, error, success
from .keystore import create_keystore_via_script
import subprocess
import sys
@click.group() @click.group()
def genesis(): def genesis():
"""Genesis block generation and management commands""" """Genesis block generation and management commands"""
pass pass
@genesis.command()
@click.option('--address', required=True, help='Wallet address (id) to create')
@click.option('--password-file', default='/opt/aitbc/data/keystore/.password', show_default=True, type=click.Path(exists=True, dir_okay=False), help='Path to password file')
@click.option('--output-dir', default='/opt/aitbc/data/keystore', show_default=True, help='Directory to write keystore file')
@click.option('--force', is_flag=True, help='Overwrite existing keystore file if present')
@click.pass_context
def create_keystore(ctx, address, password_file, output_dir, force):
"""Create an encrypted keystore for a genesis/treasury address."""
try:
create_keystore_via_script(address=address, password_file=password_file, output_dir=output_dir, force=force)
success(f"Created keystore for {address} at {output_dir}")
except Exception as e:
error(f"Error creating keystore: {e}")
raise click.Abort()
@genesis.command(name="init-production")
@click.option('--chain-id', default='ait-mainnet', show_default=True, help='Chain ID to initialize')
@click.option('--genesis-file', default='data/genesis_prod.yaml', show_default=True, help='Path to genesis YAML (copy to /opt/aitbc/genesis_prod.yaml if needed)')
@click.option('--db', default='/opt/aitbc/data/ait-mainnet/chain.db', show_default=True, help='SQLite DB path')
@click.option('--force', is_flag=True, help='Overwrite existing DB (removes file if present)')
@click.pass_context
def init_production(ctx, chain_id, genesis_file, db, force):
"""Initialize production chain DB using genesis allocations."""
db_path = Path(db)
if db_path.exists() and force:
db_path.unlink()
python_bin = Path(__file__).resolve().parents[3] / 'apps' / 'blockchain-node' / '.venv' / 'bin' / 'python3'
cmd = [
str(python_bin),
str(Path(__file__).resolve().parents[3] / 'scripts' / 'init_production_genesis.py'),
'--chain-id', chain_id,
'--db', db,
]
try:
subprocess.run(cmd, check=True)
success(f"Initialized production genesis for {chain_id} at {db}")
except subprocess.CalledProcessError as e:
error(f"Genesis init failed: {e}")
raise click.Abort()
@genesis.command() @genesis.command()
@click.argument('config_file', type=click.Path(exists=True)) @click.argument('config_file', type=click.Path(exists=True))
@click.option('--output', '-o', 'output_file', help='Output file path') @click.option('--output', '-o', 'output_file', help='Output file path')

View File

@@ -0,0 +1,67 @@
import click
import importlib.util
from pathlib import Path
def _load_keystore_script():
"""Dynamically load the top-level scripts/keystore.py module."""
root = Path(__file__).resolve().parents[3] # /opt/aitbc
ks_path = root / "scripts" / "keystore.py"
spec = importlib.util.spec_from_file_location("aitbc_scripts_keystore", ks_path)
if spec is None or spec.loader is None:
raise ImportError(f"Unable to load keystore script from {ks_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@click.group()
def keystore():
"""Keystore operations (create wallets/keystores)."""
pass
@keystore.command()
@click.option("--address", required=True, help="Wallet address (id) to create")
@click.option(
"--password-file",
default="/opt/aitbc/data/keystore/.password",
show_default=True,
type=click.Path(exists=True, dir_okay=False),
help="Path to password file",
)
@click.option(
"--output",
default="/opt/aitbc/data/keystore",
show_default=True,
help="Directory to write keystore files",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing keystore file if present",
)
@click.pass_context
def create(ctx, address: str, password_file: str, output: str, force: bool):
"""Create an encrypted keystore for the given address.
Examples:
aitbc keystore create --address aitbc1genesis
aitbc keystore create --address aitbc1treasury --password-file keystore/.password --output keystore
"""
pwd_path = Path(password_file)
with open(pwd_path, "r", encoding="utf-8") as f:
password = f.read().strip()
out_dir = Path(output) if output else Path("/opt/aitbc/data/keystore")
out_dir.mkdir(parents=True, exist_ok=True)
ks_module = _load_keystore_script()
ks_module.create_keystore(address=address, password=password, keystore_dir=out_dir, force=force)
click.echo(f"Created keystore for {address} at {out_dir}")
# Helper so other commands (genesis) can reuse the same logic
def create_keystore_via_script(address: str, password_file: str = "/opt/aitbc/data/keystore/.password", output_dir: str = "/opt/aitbc/data/keystore", force: bool = False):
pwd = Path(password_file).read_text(encoding="utf-8").strip()
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
ks_module = _load_keystore_script()
ks_module.create_keystore(address=address, password=pwd, keystore_dir=out_dir, force=force)

View File

@@ -46,6 +46,7 @@ from .commands.marketplace_advanced import advanced # Re-enabled after fixing r
from .commands.swarm import swarm from .commands.swarm import swarm
from .commands.chain import chain from .commands.chain import chain
from .commands.genesis import genesis from .commands.genesis import genesis
from .commands.keystore import keystore
from .commands.test_cli import test from .commands.test_cli import test
from .commands.node import node from .commands.node import node
from .commands.analytics import analytics from .commands.analytics import analytics
@@ -256,6 +257,7 @@ cli.add_command(ai_group)
cli.add_command(swarm) cli.add_command(swarm)
cli.add_command(chain) cli.add_command(chain)
cli.add_command(genesis) cli.add_command(genesis)
cli.add_command(keystore)
cli.add_command(test) cli.add_command(test)
cli.add_command(node) cli.add_command(node)
cli.add_command(analytics) cli.add_command(analytics)

View File

@@ -38,10 +38,10 @@ def check_build_tests():
rc, out = sh("poetry check") rc, out = sh("poetry check")
checks.append(("poetry check", rc == 0, out)) checks.append(("poetry check", rc == 0, out))
# 2) Fast syntax check of CLI package # 2) Fast syntax check of CLI package
rc, out = sh("python -m py_compile cli/aitbc_cli/__main__.py") rc, out = sh("python3 -m py_compile cli/aitbc_cli/main.py")
checks.append(("cli syntax", rc == 0, out if rc != 0 else "OK")) checks.append(("cli syntax", rc == 0, out if rc != 0 else "OK"))
# 3) Minimal test run (dry-run or 1 quick test) # 3) Minimal test run (dry-run or 1 quick test)
rc, out = sh("python -m pytest tests/ -v --collect-only 2>&1 | head -20") rc, out = sh("python3 -m pytest tests/ -v --collect-only 2>&1 | head -20")
tests_ok = rc == 0 tests_ok = rc == 0
checks.append(("test discovery", tests_ok, out if not tests_ok else f"Collected {out.count('test') if 'test' in out else '?'} tests")) checks.append(("test discovery", tests_ok, out if not tests_ok else f"Collected {out.count('test') if 'test' in out else '?'} tests"))
all_ok = all(ok for _, ok, _ in checks) all_ok = all(ok for _, ok, _ in checks)
@@ -86,7 +86,7 @@ def check_vulnerabilities():
"""Run security audits for Python and Node dependencies.""" """Run security audits for Python and Node dependencies."""
issues = [] issues = []
# Python: pip-audit (if available) # Python: pip-audit (if available)
rc, out = sh("bash -c 'pip-audit --requirement <(poetry export --without-hashes) 2>&1'") rc, out = sh("pip-audit --requirement <(poetry export --without-hashes) 2>&1")
if rc == 0: if rc == 0:
# No vulnerabilities # No vulnerabilities
pass pass

1866
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -147,7 +147,7 @@ dev = [
"black==24.3.0", "black==24.3.0",
"isort==8.0.1", "isort==8.0.1",
"ruff==0.15.5", "ruff==0.15.5",
"mypy==1.8.0", "mypy>=1.19.1,<2.0.0",
"bandit==1.7.5", "bandit==1.7.5",
"types-requests==2.31.0", "types-requests==2.31.0",
"types-setuptools==69.0.0", "types-setuptools==69.0.0",

View File

@@ -128,8 +128,8 @@ def main() -> None:
if args.db_path: if args.db_path:
os.environ["DB_PATH"] = str(args.db_path) os.environ["DB_PATH"] = str(args.db_path)
from aitbc_chain.config import Settings from aitbc_chain.config import ChainSettings
settings = Settings() settings = ChainSettings()
print(f"[*] Initializing database at {settings.db_path}") print(f"[*] Initializing database at {settings.db_path}")
init_db() init_db()

View File

@@ -41,6 +41,28 @@ def encrypt_private_key(private_key_hex: str, password: str) -> dict:
} }
def create_keystore(address: str, password: str, keystore_dir: Path | str = "/opt/aitbc/keystore", force: bool = False) -> Path:
"""Create encrypted keystore file and return its path."""
keystore_dir = Path(keystore_dir)
keystore_dir.mkdir(parents=True, exist_ok=True)
out_file = keystore_dir / f"{address}.json"
if out_file.exists() and not force:
raise FileExistsError(f"Keystore file {out_file} exists. Use force=True to overwrite.")
private_key = secrets.token_hex(32)
encrypted = encrypt_private_key(private_key, password)
keystore = {
"address": address,
"crypto": encrypted,
"created_at": datetime.utcnow().isoformat() + "Z",
}
out_file.write_text(json.dumps(keystore, indent=2))
os.chmod(out_file, 0o600)
return out_file
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Generate encrypted keystore for an account") parser = argparse.ArgumentParser(description="Generate encrypted keystore for an account")
parser.add_argument("address", help="Account address (e.g., aitbc1treasury)") parser.add_argument("address", help="Account address (e.g., aitbc1treasury)")

View File

@@ -39,7 +39,7 @@ def main():
# 1. Keystore directory and password # 1. Keystore directory and password
run(f"mkdir -p {KEYS_DIR}") run(f"mkdir -p {KEYS_DIR}")
run(f"chown -R aitbc:aitbc {KEYS_DIR}") run(f"chown -R root:root {KEYS_DIR}")
if not PASSWORD_FILE.exists(): if not PASSWORD_FILE.exists():
run(f"openssl rand -hex 32 > {PASSWORD_FILE}") run(f"openssl rand -hex 32 > {PASSWORD_FILE}")
run(f"chmod 600 {PASSWORD_FILE}") run(f"chmod 600 {PASSWORD_FILE}")
@@ -48,7 +48,7 @@ def main():
# 2. Generate keystores # 2. Generate keystores
print("\n=== Generating keystore for aitbc1genesis ===") print("\n=== Generating keystore for aitbc1genesis ===")
result = run( result = run(
f"sudo -u aitbc {NODE_VENV} /opt/aitbc/scripts/keystore.py aitbc1genesis --output-dir {KEYS_DIR} --force", f"{NODE_VENV} /opt/aitbc/scripts/keystore.py aitbc1genesis --output-dir {KEYS_DIR} --force",
capture_output=True capture_output=True
) )
print(result.stdout) print(result.stdout)
@@ -65,7 +65,7 @@ def main():
print("\n=== Generating keystore for aitbc1treasury ===") print("\n=== Generating keystore for aitbc1treasury ===")
result = run( result = run(
f"sudo -u aitbc {NODE_VENV} /opt/aitbc/scripts/keystore.py aitbc1treasury --output-dir {KEYS_DIR} --force", f"{NODE_VENV} /opt/aitbc/scripts/keystore.py aitbc1treasury --output-dir {KEYS_DIR} --force",
capture_output=True capture_output=True
) )
print(result.stdout) print(result.stdout)
@@ -82,12 +82,12 @@ def main():
# 3. Data directory # 3. Data directory
run(f"mkdir -p {DATA_DIR}") run(f"mkdir -p {DATA_DIR}")
run(f"chown -R aitbc:aitbc {DATA_DIR}") run(f"chown -R root:root {DATA_DIR}")
# 4. Initialize DB # 4. Initialize DB
os.environ["DB_PATH"] = str(DB_PATH) os.environ["DB_PATH"] = str(DB_PATH)
os.environ["CHAIN_ID"] = CHAIN_ID os.environ["CHAIN_ID"] = CHAIN_ID
run(f"sudo -E -u aitbc {NODE_VENV} /opt/aitbc/scripts/init_production_genesis.py --chain-id {CHAIN_ID} --db-path {DB_PATH}") run(f"sudo -E {NODE_VENV} /opt/aitbc/scripts/init_production_genesis.py --chain-id {CHAIN_ID} --db-path {DB_PATH}")
# 5. Write .env for blockchain node # 5. Write .env for blockchain node
env_content = f"""CHAIN_ID={CHAIN_ID} env_content = f"""CHAIN_ID={CHAIN_ID}