diff --git a/.gitea/workflows/aitbc.code-workspace b/.gitea/workflows/aitbc.code-workspace deleted file mode 100644 index 6f2e84b2..00000000 --- a/.gitea/workflows/aitbc.code-workspace +++ /dev/null @@ -1,16 +0,0 @@ -{ - "folders": [ - { - "path": "../.." - }, - { - "path": "../../../../var/lib/aitbc" - }, - { - "path": "../../../../etc/aitbc" - }, - { - "path": "../../../../var/log/aitbc" - } - ] -} \ No newline at end of file diff --git a/.gitea/workflows/python-tests.yml b/.gitea/workflows/python-tests.yml index cad9fc1b..16ae314f 100644 --- a/.gitea/workflows/python-tests.yml +++ b/.gitea/workflows/python-tests.yml @@ -57,6 +57,11 @@ jobs: --extra-packages "pytest pytest-cov pytest-mock pytest-timeout pytest-asyncio locust pydantic-settings fastapi uvicorn aiohttp>=3.12.14 sqlmodel>=0.0.38 PyJWT" echo "โ Python environment ready" + - name: Check requirements.txt sync + run: | + cd "${{ env.WORKSPACE }}/repo" + venv/bin/python scripts/ci/check-requirements-sync.py + - name: Run linting run: | cd "${{ env.WORKSPACE }}/repo" diff --git a/apps/agent-coordinator/src/app/config.py b/apps/agent-coordinator/src/app/config.py index 41b4a2e4..69401a36 100644 --- a/apps/agent-coordinator/src/app/config.py +++ b/apps/agent-coordinator/src/app/config.py @@ -12,6 +12,13 @@ except ImportError: SettingsConfigDict = None from enum import Enum + +def validated_cors_origins(origins: list[str]) -> list[str]: + if "*" in origins: + raise ValueError("Wildcard CORS origins are not allowed when credentials are enabled") + return origins + + class Environment(str, Enum): """Environment types""" DEVELOPMENT = "development" @@ -76,7 +83,16 @@ class Settings(BaseSettings): # Security settings secret_key: str allowed_hosts: list = ["*"] - cors_origins: list = ["*"] + cors_origins: list[str] = [ + "http://localhost:8001", + "http://localhost:8011", + "http://localhost:8016", + "http://localhost:9001", + "http://127.0.0.1:8001", + "http://127.0.0.1:8011", + "http://127.0.0.1:8016", + "http://127.0.0.1:9001", + ] # Monitoring settings enable_metrics: bool = True diff --git a/apps/agent-coordinator/tests/test_communication_fixed.py b/apps/agent-coordinator/tests/test_communication_fixed.py deleted file mode 100644 index eb718e36..00000000 --- a/apps/agent-coordinator/tests/test_communication_fixed.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Fixed Agent Communication Tests -Resolves async/await issues and deprecation warnings -""" -import sys - -import pytest -import asyncio -from datetime import datetime, timedelta -from unittest.mock import Mock, AsyncMock -import sys -import os - -# Add the src directory to the path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from app.protocols.communication import ( - HierarchicalProtocol, PeerToPeerProtocol, BroadcastProtocol, - CommunicationManager -) -from app.protocols.message_types import ( - AgentMessage, MessageType, Priority, MessageQueue, - MessageRouter, LoadBalancer -) - -class TestAgentMessage: - """Test agent message functionality""" - - def test_message_creation(self): - """Test message creation""" - message = AgentMessage( - sender_id="agent_001", - receiver_id="agent_002", - message_type=MessageType.COORDINATION, - payload={"action": "test"}, - priority=Priority.NORMAL - ) - - assert message.sender_id == "agent_001" - assert message.receiver_id == "agent_002" - assert message.message_type == MessageType.COORDINATION - assert message.priority == Priority.NORMAL - assert "action" in message.payload - - def test_message_expiration(self): - """Test message expiration""" - old_message = AgentMessage( - sender_id="agent_001", - receiver_id="agent_002", - message_type=MessageType.COORDINATION, - payload={"action": "test"}, - priority=Priority.NORMAL, - expires_at=datetime.now() - timedelta(seconds=400) - ) - - assert old_message.is_expired() is True - - new_message = AgentMessage( - sender_id="agent_001", - receiver_id="agent_002", - message_type=MessageType.COORDINATION, - payload={"action": "test"}, - priority=Priority.NORMAL, - expires_at=datetime.now() + timedelta(seconds=400) - ) - - assert new_message.is_expired() is False - -class TestHierarchicalProtocol: - """Test hierarchical communication protocol""" - - def setup_method(self): - self.master_protocol = HierarchicalProtocol("master_001") - - @pytest.mark.asyncio - async def test_add_sub_agent(self): - """Test adding sub-agent""" - await self.master_protocol.add_sub_agent("sub-agent-001") - assert "sub-agent-001" in self.master_protocol.sub_agents - - @pytest.mark.asyncio - async def test_send_to_sub_agents(self): - """Test sending to sub-agents""" - await self.master_protocol.add_sub_agent("sub-agent-001") - await self.master_protocol.add_sub_agent("sub-agent-002") - - message = AgentMessage( - sender_id="master_001", - receiver_id="broadcast", - message_type=MessageType.COORDINATION, - payload={"action": "test"}, - priority=Priority.NORMAL - ) - - result = await self.master_protocol.send_message(message) - assert result == 2 # Sent to 2 sub-agents - -class TestPeerToPeerProtocol: - """Test peer-to-peer communication protocol""" - - def setup_method(self): - self.p2p_protocol = PeerToPeerProtocol("agent_001") - - @pytest.mark.asyncio - async def test_add_peer(self): - """Test adding peer""" - await self.p2p_protocol.add_peer("agent-002", {"endpoint": "http://localhost:8002"}) - assert "agent-002" in self.p2p_protocol.peers - - @pytest.mark.asyncio - async def test_remove_peer(self): - """Test removing peer""" - await self.p2p_protocol.add_peer("agent-002", {"endpoint": "http://localhost:8002"}) - await self.p2p_protocol.remove_peer("agent-002") - assert "agent-002" not in self.p2p_protocol.peers - - @pytest.mark.asyncio - async def test_send_to_peer(self): - """Test sending to peer""" - await self.p2p_protocol.add_peer("agent-002", {"endpoint": "http://localhost:8002"}) - - message = AgentMessage( - sender_id="agent_001", - receiver_id="agent-002", - message_type=MessageType.COORDINATION, - payload={"action": "test"}, - priority=Priority.NORMAL - ) - - result = await self.p2p_protocol.send_message(message) - assert result is True - -class TestBroadcastProtocol: - """Test broadcast communication protocol""" - - def setup_method(self): - self.broadcast_protocol = BroadcastProtocol("agent_001") - - @pytest.mark.asyncio - async def test_subscribe_unsubscribe(self): - """Test subscribe and unsubscribe""" - await self.broadcast_protocol.subscribe("agent-002") - assert "agent-002" in self.broadcast_protocol.subscribers - - await self.broadcast_protocol.unsubscribe("agent-002") - assert "agent-002" not in self.broadcast_protocol.subscribers - - @pytest.mark.asyncio - async def test_broadcast(self): - """Test broadcasting""" - await self.broadcast_protocol.subscribe("agent-002") - await self.broadcast_protocol.subscribe("agent-003") - - message = AgentMessage( - sender_id="agent_001", - receiver_id="broadcast", - message_type=MessageType.COORDINATION, - payload={"action": "test"}, - priority=Priority.NORMAL - ) - - result = await self.broadcast_protocol.send_message(message) - assert result == 2 # Sent to 2 subscribers - -class TestCommunicationManager: - """Test communication manager""" - - def setup_method(self): - self.comm_manager = CommunicationManager("agent_001") - - @pytest.mark.asyncio - async def test_send_message(self): - """Test sending message through manager""" - message = AgentMessage( - sender_id="agent_001", - receiver_id="agent_002", - message_type=MessageType.COORDINATION, - payload={"action": "test"}, - priority=Priority.NORMAL - ) - - result = await self.comm_manager.send_message(message) - assert result is True - -class TestMessageTemplates: - """Test message templates""" - - def test_create_heartbeat(self): - """Test heartbeat message creation""" - from app.protocols.communication import create_heartbeat_message - - heartbeat = create_heartbeat_message("agent_001", "agent_002") - assert heartbeat.message_type == MessageType.HEARTBEAT - assert heartbeat.sender_id == "agent_001" - assert heartbeat.receiver_id == "agent_002" - -class TestCommunicationIntegration: - """Integration tests for communication""" - - @pytest.mark.asyncio - async def test_message_flow(self): - """Test message flow between protocols""" - # Create protocols - master = HierarchicalProtocol("master") - sub1 = PeerToPeerProtocol("sub1") - sub2 = PeerToPeerProtocol("sub2") - - # Setup hierarchy - await master.add_sub_agent("sub1") - await master.add_sub_agent("sub2") - - # Create message - message = AgentMessage( - sender_id="master", - receiver_id="broadcast", - message_type=MessageType.COORDINATION, - payload={"action": "test_flow"}, - priority=Priority.NORMAL - ) - - # Send message - result = await master.send_message(message) - assert result == 2 - -if __name__ == '__main__': - pytest.main([__file__]) diff --git a/apps/agent-coordinator/tests/test_security_agent_coordinator.py b/apps/agent-coordinator/tests/test_security_agent_coordinator.py new file mode 100644 index 00000000..bcf80d83 --- /dev/null +++ b/apps/agent-coordinator/tests/test_security_agent_coordinator.py @@ -0,0 +1,24 @@ +"""Security configuration tests for agent coordinator.""" + +import os +import sys +from pathlib import Path + +import pytest + +app_root = Path(__file__).resolve().parents[1] +if str(app_root) not in sys.path: + sys.path.insert(0, str(app_root)) + +os.environ.setdefault("SECRET_KEY", "test-secret-key") + +from src.app.config import settings, validated_cors_origins + + +def test_default_cors_origins_do_not_allow_wildcard(): + assert "*" not in settings.cors_origins + + +def test_wildcard_cors_origin_rejected(): + with pytest.raises(ValueError): + validated_cors_origins(["*"]) diff --git a/apps/agent-management/pyproject.toml b/apps/agent-management/pyproject.toml index dd54f4de..8cfdc2ad 100644 --- a/apps/agent-management/pyproject.toml +++ b/apps/agent-management/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" packages = [{include = "app", from = "src"}] [tool.poetry.dependencies] -python = "^3.13" +python = ">=3.13.5,<3.14" aitbc = {path = "../../../"} aitbc-shared-domain = {path = "../../shared-domain"} aitbc-shared-core = {path = "../../shared-core"} diff --git a/apps/agent-management/src/app/adapters/agent_core_adapters.py b/apps/agent-management/src/app/adapters/agent_core_adapters.py new file mode 100644 index 00000000..720214a9 --- /dev/null +++ b/apps/agent-management/src/app/adapters/agent_core_adapters.py @@ -0,0 +1,201 @@ +""" +Adapters for agent-management app to implement aitbc-agent-core protocols. +Since agent-management uses coordinator-api's domain models via symlink, +these adapters wrap the shared coordinator-api implementations. +""" + +from typing import Any +from sqlmodel import Session + +# Import from coordinator-api domain (shared via symlink) +from app.domain.agent import ( + AgentExecution, + AgentStepExecution, + VerificationLevel, + AgentStatus, + StepType, +) + +# Import from coordinator-api services +from app.services.agent_coordination.security import ( + AgentSecurityManager, + AgentAuditor, + AuditEventType, + SecurityLevel, +) +from app.services.agent_coordination.agent_service import AIAgentOrchestrator + +from aitbc_agent_core.protocols.domain import ( + IAgentExecution, + IAgentStepExecution, + AgentStatus as ProtocolAgentStatus, + VerificationLevel as ProtocolVerificationLevel, + StepType as ProtocolStepType, +) +from aitbc_agent_core.protocols.security import ISecurityManager, IAuditor +from aitbc_agent_core.protocols.orchestrator import IAgentOrchestrator +from aitbc_agent_core.protocols.zk_proof import IZKProofService +from aitbc_agent_core.protocols.database import ISessionProvider + + +class AgentExecutionAdapter(IAgentExecution): + """Adapter for AgentExecution domain model""" + + def __init__(self, execution: AgentExecution): + self._execution = execution + + @property + def id(self) -> str: + return self._execution.id + + @property + def workflow_id(self) -> str: + return self._execution.workflow_id + + @property + def status(self) -> ProtocolAgentStatus: + return ProtocolAgentStatus(self._execution.status) + + @property + def verification_level(self) -> ProtocolVerificationLevel: + return ProtocolVerificationLevel(self._execution.verification_level) + + def to_dict(self) -> dict[str, Any]: + return self._execution.model_dump() + + +class AgentStepExecutionAdapter(IAgentStepExecution): + """Adapter for AgentStepExecution domain model""" + + def __init__(self, step_execution: AgentStepExecution): + self._step_execution = step_execution + + @property + def id(self) -> str: + return self._step_execution.id + + @property + def execution_id(self) -> str: + return self._step_execution.execution_id + + @property + def step_type(self) -> ProtocolStepType: + return ProtocolStepType(self._step_execution.step_type) + + def to_dict(self) -> dict[str, Any]: + return self._step_execution.model_dump() + + +class AgentSecurityManagerAdapter(ISecurityManager): + """Adapter for AgentSecurityManager""" + + def __init__(self, manager: AgentSecurityManager): + self._manager = manager + + async def validate_operation(self, operation: str, context: dict[str, Any]) -> bool: + # Delegate to app-specific implementation + # Assuming AgentSecurityManager has a validate_operation method + # If not, we need to implement the logic here + try: + # Try to call the method if it exists + if hasattr(self._manager, 'validate_operation'): + return await self._manager.validate_operation(operation, context) + # Fallback: basic validation + return True + except Exception: + # Fail closed on errors + return False + + async def audit_event(self, event_type: str, details: dict[str, Any]) -> None: + # Delegate to app-specific implementation + if hasattr(self._manager, 'audit_event'): + await self._manager.audit_event(event_type, details) + + +class AgentAuditorAdapter(IAuditor): + """Adapter for AgentAuditor""" + + def __init__(self, auditor: AgentAuditor): + self._auditor = auditor + + async def log_audit(self, event_type: str, details: dict[str, Any]) -> None: + # Delegate to app-specific implementation + if hasattr(self._auditor, 'log_audit'): + await self._auditor.log_audit(event_type, details) + elif hasattr(self._auditor, 'audit_event'): + await self._auditor.audit_event(event_type, details) + + +class AgentOrchestratorAdapter(IAgentOrchestrator): + """Adapter for AIAgentOrchestrator""" + + def __init__(self, orchestrator: AIAgentOrchestrator): + self._orchestrator = orchestrator + + async def execute_workflow( + self, + workflow_id: str, + inputs: dict[str, Any] + ) -> dict[str, Any]: + # Delegate to app-specific implementation + if hasattr(self._orchestrator, 'execute_workflow'): + return await self._orchestrator.execute_workflow(workflow_id, inputs) + # Fallback: return mock result + return { + "execution_id": f"exec_{workflow_id}", + "status": "completed", + "result": inputs, + } + + async def get_status(self, execution_id: str) -> dict[str, Any]: + # Delegate to app-specific implementation + if hasattr(self._orchestrator, 'get_status'): + return await self._orchestrator.get_status(execution_id) + # Fallback: return mock status + return { + "execution_id": execution_id, + "status": "completed", + } + + +class ZKProofServiceAdapter(IZKProofService): + """Adapter for ZK proof service (mock implementation)""" + + def __init__(self, session: Session): + self._session = session + + async def generate_zk_proof( + self, + circuit_name: str, + inputs: dict[str, Any] + ) -> dict[str, Any]: + """Mock ZK proof generation""" + from uuid import uuid4 + return { + "proof_id": f"proof_{uuid4().hex[:8]}", + "circuit_name": circuit_name, + "inputs": inputs, + "proof_size": 1024, + "generation_time": 0.1, + } + + async def verify_proof(self, proof_id: str) -> dict[str, Any]: + """Mock ZK proof verification""" + return { + "verified": True, + "verification_time": 0.05, + "details": {"mock": True} + } + + +class SessionProviderAdapter(ISessionProvider): + """Adapter for SQLModel session management""" + + def __init__(self, session_factory): + self._session_factory = session_factory + + def get_session(self) -> Session: + return self._session_factory() + + def close_session(self, session: Session) -> None: + session.close() diff --git a/apps/agent-management/src/app/services/agent_integration.py b/apps/agent-management/src/app/services/agent_integration.py index cf107f78..1ba68ef1 100755 --- a/apps/agent-management/src/app/services/agent_integration.py +++ b/apps/agent-management/src/app/services/agent_integration.py @@ -1,6 +1,10 @@ """ Agent Integration and Deployment Framework for Verifiable AI Agent Orchestration Integrates agent orchestration with existing ML ZK proof system and provides deployment tools + +MIGRATION IN PROGRESS: This file is being migrated to use shared AgentIntegrationService +from aitbc-agent-core package. See agent_integration_factory.py for the factory pattern. +After migration is complete, duplicated code will be removed. """ import asyncio @@ -23,6 +27,9 @@ from app.domain.agent import AgentExecution, AgentStepExecution, VerificationLev from ..services.agent_security import AgentAuditor, AgentSecurityManager, AuditEventType, SecurityLevel from ..services.agent_service import AIAgentOrchestrator +# Import shared service factory for gradual migration +from .agent_integration_factory import get_shared_agent_integration_service + # Mock ZKProofService for testing class ZKProofService: @@ -160,7 +167,13 @@ class AgentDeploymentInstance(SQLModel, table=True): class AgentIntegrationManager: - """Manages integration between agent orchestration and existing systems""" + """ + Manages integration between agent orchestration and existing systems + + MIGRATION IN PROGRESS: Methods are being gradually migrated to use shared + AgentIntegrationService from aitbc-agent-core. The shared service is available + via get_shared_agent_integration_service() for new implementations. + """ def __init__(self, session: Session): self.session = session @@ -168,11 +181,19 @@ class AgentIntegrationManager: self.orchestrator = AIAgentOrchestrator(session, None) # Mock coordinator client self.security_manager = AgentSecurityManager(session) self.auditor = AgentAuditor(session) + + # Access to shared service for gradual migration + self._shared_service = get_shared_agent_integration_service() async def integrate_with_zk_system( self, execution_id: str, verification_level: VerificationLevel = VerificationLevel.BASIC ) -> dict[str, Any]: - """Integrate agent execution with ZK proof system""" + """ + Integrate agent execution with ZK proof system + + MIGRATION: This method could be simplified by using self._shared_service + for deploy_agent and generate_verification_proof operations. + """ try: # Get execution details diff --git a/apps/agent-management/src/app/services/agent_integration_factory.py b/apps/agent-management/src/app/services/agent_integration_factory.py new file mode 100644 index 00000000..5ad11cf0 --- /dev/null +++ b/apps/agent-management/src/app/services/agent_integration_factory.py @@ -0,0 +1,57 @@ +""" +Factory for creating shared AgentIntegrationService with app-specific adapters. +This enables gradual migration from duplicated code to shared implementation. +""" + +from sqlmodel import Session + +from aitbc_agent_core import AgentIntegrationService +from .adapters.agent_core_adapters import ( + AgentSecurityManagerAdapter, + AgentAuditorAdapter, + AgentOrchestratorAdapter, + ZKProofServiceAdapter, + SessionProviderAdapter, +) +from .agent_security import AgentSecurityManager, AgentAuditor +from .agent_service import AIAgentOrchestrator +from ..database import get_session + + +def create_agent_integration_service() -> AgentIntegrationService: + """ + Factory to create shared AgentIntegrationService with app-specific adapters. + + Returns: + Configured AgentIntegrationService instance + """ + # Create app-specific service instances + security_manager = AgentSecurityManager() + auditor = AgentAuditor() + orchestrator = AIAgentOrchestrator() + + # Wrap with protocol adapters + return AgentIntegrationService( + session_provider=SessionProviderAdapter(get_session), + security_manager=AgentSecurityManagerAdapter(security_manager), + auditor=AgentAuditorAdapter(auditor), + orchestrator=AgentOrchestratorAdapter(orchestrator), + zk_proof_service=ZKProofServiceAdapter(get_session()), + ) + + +# Singleton instance for app-wide use +_shared_service: AgentIntegrationService | None = None + + +def get_shared_agent_integration_service() -> AgentIntegrationService: + """ + Get or create the shared AgentIntegrationService singleton. + + Returns: + Shared AgentIntegrationService instance + """ + global _shared_service + if _shared_service is None: + _shared_service = create_agent_integration_service() + return _shared_service diff --git a/apps/agent-management/tests/test_agent_communication_regression.py b/apps/agent-management/tests/test_agent_communication_regression.py new file mode 100644 index 00000000..8657e8b7 --- /dev/null +++ b/apps/agent-management/tests/test_agent_communication_regression.py @@ -0,0 +1,191 @@ +""" +Regression tests for agent_communication.py +These tests capture current behavior before extracting shared logic. +""" + +import pytest +from datetime import datetime, timezone, timedelta +from uuid import uuid4 + +from app.services.agent_communication import ( + MessageType, + ChannelType, + MessageStatus, + EncryptionType, + Message, + CommunicationChannel, +) + + +@pytest.mark.unit +class TestMessageType: + """Test MessageType enum""" + + def test_message_type_values(self): + """Test that all expected message type values exist""" + assert MessageType.TEXT == "text" + assert MessageType.DATA == "data" + assert MessageType.TASK_REQUEST == "task_request" + assert MessageType.TASK_RESPONSE == "task_response" + assert MessageType.COLLABORATION == "collaboration" + assert MessageType.NOTIFICATION == "notification" + assert MessageType.SYSTEM == "system" + assert MessageType.URGENT == "urgent" + assert MessageType.BULK == "bulk" + + +@pytest.mark.unit +class TestChannelType: + """Test ChannelType enum""" + + def test_channel_type_values(self): + """Test that all expected channel type values exist""" + assert ChannelType.DIRECT == "direct" + assert ChannelType.GROUP == "group" + assert ChannelType.BROADCAST == "broadcast" + assert ChannelType.PRIVATE == "private" + + +@pytest.mark.unit +class TestMessageStatus: + """Test MessageStatus enum""" + + def test_message_status_values(self): + """Test that all expected message status values exist""" + assert MessageStatus.PENDING == "pending" + assert MessageStatus.DELIVERED == "delivered" + assert MessageStatus.READ == "read" + assert MessageStatus.FAILED == "failed" + assert MessageStatus.EXPIRED == "expired" + + +@pytest.mark.unit +class TestEncryptionType: + """Test EncryptionType enum""" + + def test_encryption_type_values(self): + """Test that all expected encryption type values exist""" + assert EncryptionType.AES256 == "aes256" + assert EncryptionType.RSA == "rsa" + assert EncryptionType.HYBRID == "hybrid" + assert EncryptionType.NONE == "none" + + +@pytest.mark.unit +class TestMessage: + """Test Message dataclass""" + + def test_message_creation(self): + """Test creating a message with default values""" + msg = Message( + id="msg_123", + sender="agent1", + recipient="agent2", + message_type=MessageType.TEXT, + content=b"test content", + encryption_key=b"key", + encryption_type=EncryptionType.AES256, + size=12, + timestamp=datetime.now(timezone.utc) + ) + + assert msg.id == "msg_123" + assert msg.sender == "agent1" + assert msg.recipient == "agent2" + assert msg.message_type == MessageType.TEXT + assert msg.content == b"test content" + assert msg.encryption_key == b"key" + assert msg.encryption_type == EncryptionType.AES256 + assert msg.size == 12 + assert msg.status == MessageStatus.PENDING + assert msg.paid is False + assert msg.price == 0.0 + assert msg.metadata == {} + assert msg.delivery_timestamp is None + assert msg.read_timestamp is None + assert msg.expires_at is None + assert msg.reply_to is None + assert msg.thread_id is None + + def test_message_with_optional_fields(self): + """Test creating a message with optional fields set""" + now = datetime.now(timezone.utc) + msg = Message( + id="msg_456", + sender="agent1", + recipient="agent2", + message_type=MessageType.TASK_REQUEST, + content=b"task data", + encryption_key=b"key", + encryption_type=EncryptionType.HYBRID, + size=9, + timestamp=now, + delivery_timestamp=now + timedelta(seconds=1), + read_timestamp=now + timedelta(seconds=2), + status=MessageStatus.READ, + paid=True, + price=0.5, + metadata={"priority": "high"}, + expires_at=now + timedelta(hours=1), + reply_to="msg_123", + thread_id="thread_1" + ) + + assert msg.delivery_timestamp is not None + assert msg.read_timestamp is not None + assert msg.status == MessageStatus.READ + assert msg.paid is True + assert msg.price == 0.5 + assert msg.metadata == {"priority": "high"} + assert msg.expires_at is not None + assert msg.reply_to == "msg_123" + assert msg.thread_id == "thread_1" + + +@pytest.mark.unit +class TestCommunicationChannel: + """Test CommunicationChannel dataclass""" + + def test_channel_creation(self): + """Test creating a communication channel with default values""" + now = datetime.now(timezone.utc) + channel = CommunicationChannel( + id="channel_123", + agent1="agent1", + agent2="agent2", + channel_type=ChannelType.DIRECT, + is_active=True, + created_timestamp=now, + last_activity=now, + message_count=0 + ) + + assert channel.id == "channel_123" + assert channel.agent1 == "agent1" + assert channel.agent2 == "agent2" + assert channel.channel_type == ChannelType.DIRECT + assert channel.is_active is True + assert channel.message_count == 0 + assert channel.participants == [] + assert channel.encryption_enabled is True + + def test_channel_with_optional_fields(self): + """Test creating a channel with optional fields set""" + now = datetime.now(timezone.utc) + channel = CommunicationChannel( + id="channel_456", + agent1="agent1", + agent2="agent2", + channel_type=ChannelType.GROUP, + is_active=True, + created_timestamp=now, + last_activity=now, + message_count=10, + participants=["agent1", "agent2", "agent3"], + encryption_enabled=False + ) + + assert channel.channel_type == ChannelType.GROUP + assert channel.message_count == 10 + assert channel.participants == ["agent1", "agent2", "agent3"] + assert channel.encryption_enabled is False diff --git a/apps/agent-management/tests/test_agent_integration_regression.py b/apps/agent-management/tests/test_agent_integration_regression.py new file mode 100644 index 00000000..346d06c4 --- /dev/null +++ b/apps/agent-management/tests/test_agent_integration_regression.py @@ -0,0 +1,118 @@ +""" +Regression tests for agent_integration.py +These tests capture current behavior before extracting shared logic. +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from datetime import datetime, timezone +from uuid import uuid4 + +from app.services.agent_integration import ( + DeploymentStatus, + AgentDeploymentConfig, + ZKProofService, +) + + +@pytest.mark.unit +class TestDeploymentStatus: + """Test DeploymentStatus enum""" + + def test_deployment_status_values(self): + """Test that all expected status values exist""" + assert DeploymentStatus.PENDING == "pending" + assert DeploymentStatus.DEPLOYING == "deploying" + assert DeploymentStatus.DEPLOYED == "deployed" + assert DeploymentStatus.FAILED == "failed" + assert DeploymentStatus.RETRYING == "retrying" + assert DeploymentStatus.TERMINATED == "terminated" + + +@pytest.mark.unit +class TestAgentDeploymentConfig: + """Test AgentDeploymentConfig model""" + + def test_default_values(self): + """Test default configuration values""" + config = AgentDeploymentConfig( + workflow_id="test_workflow", + deployment_name="test_deployment" + ) + + assert config.id.startswith("deploy_") + assert config.workflow_id == "test_workflow" + assert config.deployment_name == "test_deployment" + assert config.version == "1.0.0" + assert config.min_cpu_cores == 1.0 + assert config.min_memory_mb == 1024 + assert config.min_storage_gb == 10 + assert config.requires_gpu is False + assert config.gpu_memory_mb is None + assert config.min_instances == 1 + assert config.max_instances == 5 + assert config.auto_scaling is True + assert config.health_check_endpoint == "/health" + assert config.health_check_interval == 30 + assert config.health_check_timeout == 10 + assert config.max_failures == 3 + assert config.rollout_strategy == "rolling" + assert config.rollback_enabled is True + assert config.deployment_timeout == 1800 + + def test_custom_values(self): + """Test custom configuration values""" + config = AgentDeploymentConfig( + workflow_id="custom_workflow", + deployment_name="custom_deployment", + version="2.0.0", + min_cpu_cores=4.0, + min_memory_mb=8192, + requires_gpu=True, + gpu_memory_mb=16384, + min_instances=2, + max_instances=10, + auto_scaling=False, + rollout_strategy="blue-green" + ) + + assert config.version == "2.0.0" + assert config.min_cpu_cores == 4.0 + assert config.min_memory_mb == 8192 + assert config.requires_gpu is True + assert config.gpu_memory_mb == 16384 + assert config.min_instances == 2 + assert config.max_instances == 10 + assert config.auto_scaling is False + assert config.rollout_strategy == "blue-green" + + +@pytest.mark.unit +class TestZKProofService: + """Test ZKProofService mock""" + + @pytest.mark.asyncio + async def test_generate_zk_proof(self): + """Test ZK proof generation""" + mock_session = Mock() + service = ZKProofService(mock_session) + + result = await service.generate_zk_proof("test_circuit", {"input": "value"}) + + assert "proof_id" in result + assert result["circuit_name"] == "test_circuit" + assert result["inputs"] == {"input": "value"} + assert result["proof_size"] == 1024 + assert result["generation_time"] == 0.1 + + @pytest.mark.asyncio + async def test_verify_proof(self): + """Test ZK proof verification""" + mock_session = Mock() + service = ZKProofService(mock_session) + + result = await service.verify_proof("test_proof_id") + + assert result["verified"] is True + assert result["verification_time"] == 0.05 + assert "details" in result diff --git a/apps/agent-management/tests/test_agent_performance_service_regression.py b/apps/agent-management/tests/test_agent_performance_service_regression.py new file mode 100644 index 00000000..9f0e19c1 --- /dev/null +++ b/apps/agent-management/tests/test_agent_performance_service_regression.py @@ -0,0 +1,104 @@ +""" +Regression tests for agent_performance_service.py +These tests capture current behavior before extracting shared logic. +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from datetime import datetime, timezone +from uuid import uuid4 + +from app.services.agent_performance_service import MetaLearningEngine + + +@pytest.mark.unit +class TestMetaLearningEngine: + """Test MetaLearningEngine class""" + + def test_initialization(self): + """Test MetaLearningEngine initialization""" + engine = MetaLearningEngine() + + assert "model_agnostic_meta_learning" in engine.meta_algorithms + assert "reptile" in engine.meta_algorithms + assert "meta_sgd" in engine.meta_algorithms + assert "prototypical_networks" in engine.meta_algorithms + + assert "fast_adaptation" in engine.adaptation_strategies + assert "gradual_adaptation" in engine.adaptation_strategies + assert "transfer_adaptation" in engine.adaptation_strategies + assert "multi_task_adaptation" in engine.adaptation_strategies + + assert len(engine.performance_metrics) == 4 + + def test_meta_algorithms_callable(self): + """Test that meta algorithms are callable methods""" + engine = MetaLearningEngine() + + for algo_name, algo_func in engine.meta_algorithms.items(): + assert callable(algo_func), f"{algo_name} is not callable" + + def test_adaptation_strategies_callable(self): + """Test that adaptation strategies are callable methods""" + engine = MetaLearningEngine() + + for strategy_name, strategy_func in engine.adaptation_strategies.items(): + assert callable(strategy_func), f"{strategy_name} is not callable" + + @pytest.mark.asyncio + async def test_create_meta_learning_model(self): + """Test creating a meta-learning model""" + mock_session = Mock() + mock_session.add = Mock() + mock_session.commit = Mock() + mock_session.refresh = Mock() + + engine = MetaLearningEngine() + + with patch.object(engine, 'generate_meta_features', return_value={"feature1": "value1"}): + with patch.object(engine, 'setup_task_distributions', return_value={"dist1": "value1"}): + with patch('asyncio.create_task'): + model = await engine.create_meta_learning_model( + session=mock_session, + model_name="test_model", + base_algorithms=["algorithm1"], + meta_strategy="fast_adaptation", + adaptation_targets=["target1"] + ) + + assert model.model_name == "test_model" + assert model.base_algorithms == ["algorithm1"] + assert model.status == "training" + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + @pytest.mark.asyncio + async def test_train_meta_model_not_found(self): + """Test training a model that doesn't exist""" + mock_session = Mock() + mock_session.execute = Mock(return_value=Mock(first=Mock(return_value=None))) + + engine = MetaLearningEngine() + + with pytest.raises(ValueError, match="Meta-learning model .* not found"): + await engine.train_meta_model(mock_session, "nonexistent_model_id") + + def test_generate_meta_features(self): + """Test meta features generation""" + engine = MetaLearningEngine() + + # This is a placeholder test - the actual implementation would need to be tested + # once we understand the full behavior + features = engine.generate_meta_features(["target1", "target2"]) + + assert isinstance(features, dict) + + def test_setup_task_distributions(self): + """Test task distributions setup""" + engine = MetaLearningEngine() + + # This is a placeholder test - the actual implementation would need to be tested + # once we understand the full behavior + distributions = engine.setup_task_distributions(["target1", "target2"]) + + assert isinstance(distributions, dict) diff --git a/apps/agent-management/tests/test_agent_service_marketplace_regression.py b/apps/agent-management/tests/test_agent_service_marketplace_regression.py new file mode 100644 index 00000000..a7fa8bf4 --- /dev/null +++ b/apps/agent-management/tests/test_agent_service_marketplace_regression.py @@ -0,0 +1,164 @@ +""" +Regression tests for agent_service_marketplace.py +These tests capture current behavior before extracting shared logic. +""" + +import pytest +from datetime import datetime, timezone, timedelta +from uuid import uuid4 + +from app.services.agent_service_marketplace import ( + ServiceStatus, + RequestStatus, + GuildStatus, + ServiceType, + Service, + ServiceRequest, +) + + +@pytest.mark.unit +class TestServiceStatus: + """Test ServiceStatus enum""" + + def test_service_status_values(self): + """Test that all expected service status values exist""" + assert ServiceStatus.ACTIVE == "active" + assert ServiceStatus.INACTIVE == "inactive" + assert ServiceStatus.SUSPENDED == "suspended" + assert ServiceStatus.PENDING == "pending" + + +@pytest.mark.unit +class TestRequestStatus: + """Test RequestStatus enum""" + + def test_request_status_values(self): + """Test that all expected request status values exist""" + assert RequestStatus.PENDING == "pending" + assert RequestStatus.ACCEPTED == "accepted" + assert RequestStatus.COMPLETED == "completed" + assert RequestStatus.CANCELLED == "cancelled" + assert RequestStatus.EXPIRED == "expired" + + +@pytest.mark.unit +class TestGuildStatus: + """Test GuildStatus enum""" + + def test_guild_status_values(self): + """Test that all expected guild status values exist""" + assert GuildStatus.ACTIVE == "active" + assert GuildStatus.INACTIVE == "inactive" + assert GuildStatus.SUSPENDED == "suspended" + + +@pytest.mark.unit +class TestServiceType: + """Test ServiceType enum""" + + def test_service_type_values(self): + """Test that all expected service type values exist""" + assert ServiceType.DATA_ANALYSIS == "data_analysis" + assert ServiceType.CONTENT_CREATION == "content_creation" + assert ServiceType.RESEARCH == "research" + assert ServiceType.CONSULTING == "consulting" + assert ServiceType.DEVELOPMENT == "development" + assert ServiceType.DESIGN == "design" + assert ServiceType.MARKETING == "marketing" + assert ServiceType.TRANSLATION == "translation" + assert ServiceType.WRITING == "writing" + assert ServiceType.ANALYSIS == "analysis" + assert ServiceType.PREDICTION == "prediction" + assert ServiceType.OPTIMIZATION == "optimization" + assert ServiceType.AUTOMATION == "automation" + assert ServiceType.MONITORING == "monitoring" + assert ServiceType.TESTING == "testing" + assert ServiceType.SECURITY == "security" + assert ServiceType.INTEGRATION == "integration" + assert ServiceType.CUSTOMIZATION == "customization" + assert ServiceType.TRAINING == "training" + assert ServiceType.SUPPORT == "support" + + +@pytest.mark.unit +class TestService: + """Test Service dataclass""" + + def test_service_creation_with_defaults(self): + """Test creating a service with default values""" + now = datetime.now(timezone.utc) + service = Service( + id="service_123", + agent_id="agent1", + service_type=ServiceType.DEVELOPMENT, + name="Test Service", + description="A test service", + metadata={"key": "value"}, + base_price=100.0, + reputation=5, + status=ServiceStatus.ACTIVE, + total_earnings=1000.0, + completed_jobs=10, + average_rating=4.5, + rating_count=8, + listed_at=now, + last_updated=now + ) + + assert service.id == "service_123" + assert service.agent_id == "agent1" + assert service.service_type == ServiceType.DEVELOPMENT + assert service.name == "Test Service" + assert service.description == "A test service" + assert service.metadata == {"key": "value"} + assert service.base_price == 100.0 + assert service.reputation == 5 + assert service.status == ServiceStatus.ACTIVE + assert service.total_earnings == 1000.0 + assert service.completed_jobs == 10 + assert service.average_rating == 4.5 + assert service.rating_count == 8 + assert service.guild_id is None + assert service.tags == [] + assert service.capabilities == [] + assert service.requirements == [] + assert service.pricing_model == "fixed" + assert service.estimated_duration == 0 + assert service.availability == {} + + def test_service_with_optional_fields(self): + """Test creating a service with optional fields set""" + now = datetime.now(timezone.utc) + service = Service( + id="service_456", + agent_id="agent2", + service_type=ServiceType.DATA_ANALYSIS, + name="Data Analysis Service", + description="Professional data analysis", + metadata={"complexity": "high"}, + base_price=250.0, + reputation=10, + status=ServiceStatus.ACTIVE, + total_earnings=5000.0, + completed_jobs=50, + average_rating=4.8, + rating_count=45, + listed_at=now, + last_updated=now, + guild_id="guild_123", + tags=["data", "analysis", "python"], + capabilities=["ml", "visualization"], + requirements=["dataset", "clear_objectives"], + pricing_model="hourly", + estimated_duration=5, + availability={"monday": True, "tuesday": True} + ) + + assert service.guild_id == "guild_123" + assert service.tags == ["data", "analysis", "python"] + assert service.capabilities == ["ml", "visualization"] + assert service.requirements == ["dataset", "clear_objectives"] + assert service.pricing_model == "hourly" + assert service.estimated_duration == 5 + assert service.availability == {"monday": True, "tuesday": True} diff --git a/apps/aitbc-edge/pyproject.toml b/apps/aitbc-edge/pyproject.toml index da0f4147..055fee3a 100644 --- a/apps/aitbc-edge/pyproject.toml +++ b/apps/aitbc-edge/pyproject.toml @@ -2,7 +2,7 @@ name = "aitbc-edge" version = "0.1.0" description = "Edge API Service for AITBC island and edge operations" -requires-python = ">=3.13" +requires-python = ">=3.13.5" dependencies = [ "fastapi>=0.115.6", "uvicorn>=0.34.0", diff --git a/apps/blockchain-node/fix_accounts.py b/apps/blockchain-node/fix_accounts.py deleted file mode 100644 index e92f13d0..00000000 --- a/apps/blockchain-node/fix_accounts.py +++ /dev/null @@ -1,14 +0,0 @@ -from aitbc_chain.database import session_scope, init_db -from aitbc_chain.models import Account -from datetime import datetime, timezone - -def fix(): - init_db() - with session_scope() as session: - acc = Account(chain_id="ait-mainnet", address="aitbc1genesis", balance=10000000, nonce=0, updated_at=datetime.now(timezone.utc), account_type="regular", metadata="{}") - session.merge(acc) - session.commit() - print("Added aitbc1genesis to mainnet") - -if __name__ == "__main__": - fix() diff --git a/apps/blockchain-node/fix_block_metadata.py b/apps/blockchain-node/fix_block_metadata.py deleted file mode 100644 index 8d32a8af..00000000 --- a/apps/blockchain-node/fix_block_metadata.py +++ /dev/null @@ -1,27 +0,0 @@ -import sqlite3 - -def fix(): - try: - conn = sqlite3.connect('/var/lib/aitbc/data/ait-mainnet/chain.db') - cur = conn.cursor() - - cur.execute('PRAGMA table_info("block")') - columns = [col[1] for col in cur.fetchall()] - - if 'metadata' in columns: - print("Renaming metadata column to block_metadata...") - cur.execute('ALTER TABLE "block" RENAME COLUMN metadata TO block_metadata') - conn.commit() - elif 'block_metadata' not in columns: - print("Adding block_metadata column...") - cur.execute('ALTER TABLE "block" ADD COLUMN block_metadata TEXT') - conn.commit() - else: - print("block_metadata column already exists.") - - conn.close() - except Exception as e: - print(f"Error modifying database: {e}") - -if __name__ == "__main__": - fix() diff --git a/apps/blockchain-node/fix_block_metadata2.py b/apps/blockchain-node/fix_block_metadata2.py deleted file mode 100644 index dcbe62bf..00000000 --- a/apps/blockchain-node/fix_block_metadata2.py +++ /dev/null @@ -1,39 +0,0 @@ -import sqlite3 - -def fix(): - try: - conn = sqlite3.connect('/var/lib/aitbc/data/chain.db') - cur = conn.cursor() - - cur.execute('PRAGMA table_info("block")') - columns = [col[1] for col in cur.fetchall()] - - if 'metadata' in columns: - print("Renaming metadata column to block_metadata in default db...") - cur.execute('ALTER TABLE "block" RENAME COLUMN metadata TO block_metadata') - conn.commit() - elif 'block_metadata' not in columns: - print("Adding block_metadata column to default db...") - cur.execute('ALTER TABLE "block" ADD COLUMN block_metadata TEXT') - conn.commit() - else: - print("block_metadata column already exists in default db.") - - cur.execute('PRAGMA table_info("transaction")') - columns = [col[1] for col in cur.fetchall()] - - if 'metadata' in columns: - print("Renaming metadata column to tx_metadata in default db...") - cur.execute('ALTER TABLE "transaction" RENAME COLUMN metadata TO tx_metadata') - conn.commit() - elif 'tx_metadata' not in columns: - print("Adding tx_metadata column to default db...") - cur.execute('ALTER TABLE "transaction" ADD COLUMN tx_metadata TEXT') - conn.commit() - - conn.close() - except Exception as e: - print(f"Error modifying database: {e}") - -if __name__ == "__main__": - fix() diff --git a/apps/blockchain-node/fix_db.py b/apps/blockchain-node/fix_db.py deleted file mode 100644 index 5609a5ab..00000000 --- a/apps/blockchain-node/fix_db.py +++ /dev/null @@ -1,41 +0,0 @@ -from aitbc_chain.database import get_engine, init_db -from sqlalchemy import text - -def fix(): - init_db() - engine = get_engine() - with engine.connect() as conn: - try: - conn.execute(text('ALTER TABLE "transaction" ADD COLUMN metadata TEXT')) - print("Added metadata") - except Exception as e: - pass - try: - conn.execute(text('ALTER TABLE "transaction" ADD COLUMN value INTEGER DEFAULT 0')) - print("Added value") - except Exception as e: - pass - try: - conn.execute(text('ALTER TABLE "transaction" ADD COLUMN fee INTEGER DEFAULT 0')) - print("Added fee") - except Exception as e: - pass - try: - conn.execute(text('ALTER TABLE "transaction" ADD COLUMN nonce INTEGER DEFAULT 0')) - print("Added nonce") - except Exception as e: - pass - try: - conn.execute(text('ALTER TABLE "transaction" ADD COLUMN status TEXT DEFAULT "pending"')) - print("Added status") - except Exception as e: - pass - try: - conn.execute(text('ALTER TABLE "transaction" ADD COLUMN timestamp TEXT')) - print("Added timestamp") - except Exception as e: - pass - conn.commit() - -if __name__ == "__main__": - fix() diff --git a/apps/blockchain-node/fix_env_path.py b/apps/blockchain-node/fix_env_path.py deleted file mode 100644 index 3a70d97e..00000000 --- a/apps/blockchain-node/fix_env_path.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic_settings import BaseSettings, SettingsConfigDict -class TestSettings(BaseSettings): - model_config = SettingsConfigDict(env_file="/etc/aitbc/blockchain.env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore") - db_path: str = "" -print(TestSettings().db_path) diff --git a/apps/blockchain-node/fix_tx_metadata.py b/apps/blockchain-node/fix_tx_metadata.py deleted file mode 100644 index 7a77def9..00000000 --- a/apps/blockchain-node/fix_tx_metadata.py +++ /dev/null @@ -1,27 +0,0 @@ -import sqlite3 - -def fix(): - try: - conn = sqlite3.connect('/var/lib/aitbc/data/ait-mainnet/chain.db') - cur = conn.cursor() - - cur.execute('PRAGMA table_info("transaction")') - columns = [col[1] for col in cur.fetchall()] - - if 'metadata' in columns: - print("Renaming metadata column to tx_metadata...") - cur.execute('ALTER TABLE "transaction" RENAME COLUMN metadata TO tx_metadata') - conn.commit() - elif 'tx_metadata' not in columns: - print("Adding tx_metadata column...") - cur.execute('ALTER TABLE "transaction" ADD COLUMN tx_metadata TEXT') - conn.commit() - else: - print("tx_metadata column already exists.") - - conn.close() - except Exception as e: - print(f"Error modifying database: {e}") - -if __name__ == "__main__": - fix() diff --git a/apps/blockchain-node/fix_tx_metadata2.py b/apps/blockchain-node/fix_tx_metadata2.py deleted file mode 100644 index d3ddf786..00000000 --- a/apps/blockchain-node/fix_tx_metadata2.py +++ /dev/null @@ -1,50 +0,0 @@ -import sqlite3 - -def fix_db(): - print("Fixing transaction table on aitbc node...") - - conn = sqlite3.connect('/var/lib/aitbc/data/ait-mainnet/chain.db') - cursor = conn.cursor() - - try: - cursor.execute('ALTER TABLE "transaction" ADD COLUMN nonce INTEGER DEFAULT 0;') - print("Added nonce column") - except sqlite3.OperationalError as e: - print(f"Error adding nonce: {e}") - - try: - cursor.execute('ALTER TABLE "transaction" ADD COLUMN value INTEGER DEFAULT 0;') - print("Added value column") - except sqlite3.OperationalError as e: - print(f"Error adding value: {e}") - - try: - cursor.execute('ALTER TABLE "transaction" ADD COLUMN fee INTEGER DEFAULT 0;') - print("Added fee column") - except sqlite3.OperationalError as e: - print(f"Error adding fee: {e}") - - try: - cursor.execute('ALTER TABLE "transaction" ADD COLUMN status TEXT DEFAULT "pending";') - print("Added status column") - except sqlite3.OperationalError as e: - print(f"Error adding status: {e}") - - try: - cursor.execute('ALTER TABLE "transaction" ADD COLUMN tx_metadata TEXT;') - print("Added tx_metadata column") - except sqlite3.OperationalError as e: - print(f"Error adding tx_metadata: {e}") - - try: - cursor.execute('ALTER TABLE "transaction" ADD COLUMN timestamp TEXT;') - print("Added timestamp column") - except sqlite3.OperationalError as e: - print(f"Error adding timestamp: {e}") - - conn.commit() - conn.close() - print("Done fixing transaction table.") - -if __name__ == '__main__': - fix_db() diff --git a/apps/blockchain-node/scripts/load_genesis_fixed.py b/apps/blockchain-node/scripts/load_genesis_fixed.py deleted file mode 100755 index da10219d..00000000 --- a/apps/blockchain-node/scripts/load_genesis_fixed.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -"""Load genesis accounts into the blockchain database""" - -import json -import sys -from pathlib import Path - -# Add the src directory to the path -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from aitbc_chain.database import session_scope -from aitbc_chain.models import Account -from aitbc_chain.config import settings - -def load_genesis_accounts(genesis_path: str = "data/devnet/genesis.json"): - """Load accounts from genesis file into database""" - - # Read genesis file - genesis_file = Path(genesis_path) - if not genesis_file.exists(): - print(f"Error: Genesis file not found at {genesis_path}") - return False - - with open(genesis_file) as f: - genesis = json.load(f) - - chain_id = genesis.get("chain_id", settings.chain_id) - - # Load accounts - with session_scope() as session: - for account_data in genesis.get("allocations", []): - address = account_data["address"] - balance = account_data["balance"] - nonce = account_data.get("nonce", 0) - - # Check if account already exists - existing = session.query(Account).filter_by(chain_id=chain_id, address=address).first() - if existing: - existing.balance = balance - existing.nonce = nonce - print(f"Updated account {address}: balance={balance}") - else: - account = Account(chain_id=chain_id, address=address, balance=balance, nonce=nonce) - session.add(account) - print(f"Created account {address}: balance={balance}") - - session.commit() - - print("\\nGenesis accounts loaded successfully!") - return True - -if __name__ == "__main__": - if len(sys.argv) > 1: - genesis_path = sys.argv[1] - else: - genesis_path = "data/devnet/genesis.json" - - success = load_genesis_accounts(genesis_path) - sys.exit(0 if success else 1) diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/accounts.py b/apps/blockchain-node/src/aitbc_chain/rpc/accounts.py new file mode 100644 index 00000000..378061f9 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/accounts.py @@ -0,0 +1,303 @@ +""" +Account-related RPC endpoints. +""" + +import hashlib +import uuid +from datetime import datetime, timezone +from typing import Any, Dict + +from fastapi import HTTPException, Request +from sqlmodel import select + +from ..database import session_scope +from ..models import Account, Transaction +from ..logger import get_logger +from .utils import get_chain_id +from aitbc.rate_limiting import rate_limit + +_logger = get_logger(__name__) + + +@rate_limit(rate=200, per=60) +async def get_account( + request: Request, address: str, chain_id: str = None +) -> Dict[str, Any]: + """Get account information""" + chain_id = get_chain_id(chain_id) + + with session_scope() as session: + account = session.exec(select(Account).where(Account.address == address).where(Account.chain_id == chain_id)).first() + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + return { + "address": account.address, + "balance": account.balance, + "nonce": account.nonce, + "chain_id": account.chain_id + } + + +@rate_limit(rate=200, per=60) +async def get_account_alias( + request: Request, address: str, chain_id: str = None +) -> Dict[str, Any]: + """Get account information (alias endpoint)""" + return await get_account(request, address, chain_id) + + +@rate_limit(rate=200, per=60) +async def get_account_details( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """ + Get account details including balance and nonce. + + Args: + address: The account address + chain_id: Optional chain ID (defaults to node's chain) + + Returns: + Account details or 404 if not found + """ + chain_id = get_chain_id(chain_id) + address = address.lower().strip() + + with session_scope() as session: + account = session.get(Account, (chain_id, address)) + if not account: + raise HTTPException(status_code=404, detail=f"Account {address} not found on chain {chain_id}") + + return { + "success": True, + "address": account.address, + "chain_id": account.chain_id, + "balance": account.balance, + "nonce": account.nonce, + "updated_at": account.updated_at.isoformat() if account.updated_at else None + } + + +@rate_limit(rate=100, per=60) +async def create_account( + request: Request, + account_data: dict +) -> Dict[str, Any]: + """ + Create or register a new account on the blockchain. + + This endpoint allows wallets to register their public keys as accounts + on the blockchain, enabling them to send and receive transactions. + + Args: + account_data: Dictionary containing: + - address: The account address/public key (hex string) + - chain_id: Optional chain ID (defaults to node's chain) + + Returns: + Dictionary with success status and account details + """ + chain_id = get_chain_id(account_data.get("chain_id")) + address = account_data.get("address") + + if not address: + raise HTTPException(status_code=400, detail="address is required") + + # Normalize address (ensure lowercase hex) + address = address.lower().strip() + if not address.startswith("0x"): + address = "0x" + address + + # Validate address format (should be hex) + if not all(c in "0123456789abcdef" for c in address[2:]): + raise HTTPException(status_code=400, detail="address must be a valid hex string") + + with session_scope() as session: + # Check if account already exists + existing_account = session.get(Account, (chain_id, address)) + if existing_account: + return { + "success": True, + "address": address, + "chain_id": chain_id, + "balance": existing_account.balance, + "nonce": existing_account.nonce, + "created": False, + "message": "Account already exists" + } + + # Create new account with zero balance + new_account = Account( + chain_id=chain_id, + address=address, + balance=0, + nonce=0 + ) + session.add(new_account) + session.commit() + + return { + "success": True, + "address": address, + "chain_id": chain_id, + "balance": 0, + "nonce": 0, + "created": True, + "message": "Account created successfully" + } + + +@rate_limit(rate=10, per=3600) # 10 requests per hour per IP +async def faucet_request( + request: Request, + faucet_data: dict +) -> Dict[str, Any]: + """ + Request test tokens from the blockchain faucet. + + This endpoint allows newly created wallets to receive initial funds + for testing and development purposes. + + Args: + faucet_data: Dictionary containing: + - address: The account address to fund + - amount: Optional amount to request (default: 1000000) + - chain_id: Optional chain ID (defaults to node's chain) + + Returns: + Dictionary with success status and transaction details + """ + chain_id = get_chain_id(faucet_data.get("chain_id")) + address = faucet_data.get("address") + amount = faucet_data.get("amount", 1000000) # Default 1M units + + if not address: + raise HTTPException(status_code=400, detail="address is required") + + # Normalize address + address = address.lower().strip() + if not address.startswith("0x"): + address = "0x" + address + + # Validate address format + if not all(c in "0123456789abcdef" for c in address[2:]): + raise HTTPException(status_code=400, detail="address must be a valid hex string") + + # Cap max faucet amount + if amount > 10000000: # Max 10M per request + amount = 10000000 + + with session_scope() as session: + # Check if account exists + account = session.get(Account, (chain_id, address)) + if not account: + # Auto-create account if it doesn't exist + account = Account(chain_id=chain_id, address=address, balance=0, nonce=0) + session.add(account) + session.flush() + _logger.info(f"Faucet auto-created account: {address}") + + # Generate faucet transaction (special minting transaction) + timestamp = datetime.now(timezone.utc) + tx_hash = hashlib.sha256( + f"faucet:{address}:{amount}:{timestamp.isoformat()}:{uuid.uuid4()}".encode() + ).hexdigest() + + # Apply balance update directly (faucet is special system tx) + account.balance += amount + session.add(account) + + # Create faucet transaction record + faucet_tx = Transaction( + chain_id=chain_id, + tx_hash=tx_hash, + sender="faucet", + recipient=address, + payload={"type": "FAUCET", "amount": amount, "reason": "test_funding"}, + value=amount, + fee=0, + nonce=0, + timestamp=timestamp, + block_height=None, # Not in a block - direct system tx + status="confirmed", + type="FAUCET" + ) + session.add(faucet_tx) + session.commit() + + return { + "success": True, + "address": address, + "amount": amount, + "tx_hash": tx_hash, + "chain_id": chain_id, + "message": "Faucet transaction completed" + } + + +@rate_limit(rate=100, per=60) +async def get_balance_breakdown( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """ + Get detailed balance breakdown including: + - Available balance + - Staked amount + - Bridge-locked amount + - Total balance + """ + try: + from ..services.balance_tracker import get_balance_tracker + tracker = get_balance_tracker() + + if not tracker: + raise HTTPException(status_code=503, detail="Balance tracker not initialized") + + chain_id = get_chain_id(chain_id) + address = address.lower().strip() + + breakdown = tracker.get_balance_breakdown(address, chain_id) + return breakdown + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Failed to get balance breakdown: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get balance: {str(e)}") + + +@rate_limit(rate=20, per=60) +async def reconcile_balance( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """ + Reconcile account balance against all recorded operations. + + Verifies that current balance matches expected balance + based on all transactions, stakes, and bridge operations. + """ + try: + from ..services.balance_tracker import get_balance_tracker + tracker = get_balance_tracker() + + if not tracker: + raise HTTPException(status_code=503, detail="Balance tracker not initialized") + + chain_id = get_chain_id(chain_id) + address = address.lower().strip() + + result = tracker.reconcile_balance(address, chain_id) + return result + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Balance reconciliation failed: {e}") + raise HTTPException(status_code=500, detail=f"Reconciliation failed: {str(e)}") diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/auth.py b/apps/blockchain-node/src/aitbc_chain/rpc/auth.py new file mode 100644 index 00000000..8a4dce0c --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/auth.py @@ -0,0 +1,66 @@ +""" +Authentication utilities for blockchain RPC endpoints. +""" + +import os +from typing import Optional + +from fastapi import HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials + +from ..logger import get_logger + +_logger = get_logger(__name__) + + +def get_authenticated_address(request: Request, credentials: Optional[HTTPAuthorizationCredentials] = None) -> str: + """ + Extract authenticated wallet address from request headers or JWT token. + + Priority order: + 1. X-Wallet-Address header (for API key auth) + 2. JWT Bearer token (if provided) + 3. Development mode fallback (if DEV_MODE=true) + + Returns: + str: The authenticated wallet address + + Raises: + HTTPException: If authentication fails and not in development mode + """ + # Check for X-Wallet-Address header (API key authentication) + wallet_address = request.headers.get("X-Wallet-Address") + if wallet_address: + if not wallet_address.startswith("0x") or len(wallet_address) != 42: + _logger.warning(f"Invalid wallet address format in X-Wallet-Address header: {wallet_address}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid wallet address format" + ) + if os.getenv("TRUST_X_WALLET_ADDRESS", "false").lower() != "true": + _logger.warning("Rejected untrusted X-Wallet-Address header") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="X-Wallet-Address header is not trusted without explicit server configuration" + ) + _logger.debug(f"Authenticated via X-Wallet-Address header: {wallet_address}") + return wallet_address + + # Check for JWT Bearer token + if credentials and credentials.scheme == "Bearer": + _logger.warning("JWT authentication attempted but not supported") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="JWT authentication is not supported. Use X-Wallet-Address header with TRUST_X_WALLET_ADDRESS=true for trusted internal requests." + ) + + # Development mode fallback + if os.getenv("DEV_MODE", "false").lower() == "true": + _logger.warning("Rejected unauthenticated request in development mode") + + # No valid authentication found + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required. Provide X-Wallet-Address header or valid JWT token.", + headers={"WWW-Authenticate": "Bearer"} + ) diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/blocks.py b/apps/blockchain-node/src/aitbc_chain/rpc/blocks.py new file mode 100644 index 00000000..3d42845d --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/blocks.py @@ -0,0 +1,287 @@ +""" +Block-related RPC endpoints. +""" + +import asyncio +import json +import re +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from fastapi import HTTPException, Request, status +from sqlmodel import select, delete + +from ..database import session_scope +from ..models import Block, Transaction +from ..metrics import metrics_registry +from .utils import get_chain_id +from aitbc.rate_limiting import rate_limit + +from ..logger import get_logger + +_logger = get_logger(__name__) + +# Global rate limiter for importBlock +_last_import_time = 0 +_import_lock = asyncio.Lock() + + +@rate_limit(rate=200, per=60) +async def get_genesis_allocations( + request: Request, chain_id: str = None +) -> Dict[str, Any]: + """Get genesis allocations from genesis block metadata for RPC bootstrap""" + chain_id = get_chain_id(chain_id) + + with session_scope(chain_id) as session: + # Get genesis block (height 0) + genesis = session.exec( + select(Block).where(Block.chain_id == chain_id).where(Block.height == 0) + ).first() + + if not genesis: + raise HTTPException(status_code=404, detail=f"Genesis block not found for chain {chain_id}") + + # Extract allocations from block metadata + if not genesis.block_metadata: + raise HTTPException(status_code=404, detail=f"Genesis block metadata not found for chain {chain_id}") + + try: + metadata = json.loads(genesis.block_metadata) + allocations = metadata.get("allocations", []) + return { + "chain_id": chain_id, + "allocations": allocations, + "genesis_hash": genesis.hash, + "genesis_height": genesis.height, + "genesis_state_root": genesis.state_root, + } + except json.JSONDecodeError as e: + raise HTTPException(status_code=500, detail=f"Failed to parse genesis block metadata: {e}") + + +@rate_limit(rate=200, per=60) +async def get_head( + request: Request, chain_id: str = None +) -> Dict[str, Any]: + """Get current chain head""" + chain_id = get_chain_id(chain_id) + + metrics_registry.increment("rpc_get_head_total") + start = time.perf_counter() + with session_scope(chain_id) as session: + result = session.exec(select(Block).where(Block.chain_id == chain_id).order_by(Block.height.desc()).limit(1)).first() + if result is None: + metrics_registry.increment("rpc_get_head_not_found_total") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no blocks yet") + metrics_registry.increment("rpc_get_head_success_total") + metrics_registry.observe("rpc_get_head_duration_seconds", time.perf_counter() - start) + return { + "height": result.height, + "hash": result.hash, + "timestamp": result.timestamp.isoformat(), + "tx_count": result.tx_count, + } + + +@rate_limit(rate=200, per=60) +async def get_block( + request: Request, height: int, chain_id: str = None +) -> Dict[str, Any]: + """Get block by height""" + chain_id = get_chain_id(chain_id) + + metrics_registry.increment("rpc_get_block_total") + start = time.perf_counter() + with session_scope(chain_id) as session: + block = session.exec( + select(Block).where(Block.chain_id == chain_id).where(Block.height == height) + ).first() + if block is None: + metrics_registry.increment("rpc_get_block_not_found_total") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="block not found") + metrics_registry.increment("rpc_get_block_success_total") + + txs = session.exec( + select(Transaction) + .where(Transaction.chain_id == chain_id) + .where(Transaction.block_height == height) + ).all() + tx_list = [] + for tx in txs: + t = dict(tx.payload) if tx.payload else {} + t["tx_hash"] = tx.tx_hash + tx_list.append(t) + + metrics_registry.observe("rpc_get_block_duration_seconds", time.perf_counter() - start) + return { + "chain_id": block.chain_id, + "height": block.height, + "hash": block.hash, + "parent_hash": block.parent_hash, + "proposer": block.proposer, + "timestamp": block.timestamp.isoformat(), + "tx_count": block.tx_count, + "state_root": block.state_root, + "transactions": tx_list, + } + + +@rate_limit(rate=200, per=60) +async def get_blocks_range( + request: Request, start: int = 0, end: int = 10, include_tx: bool = True, chain_id: str = None +) -> Dict[str, Any]: + """Get blocks in a height range + + Args: + start: Starting block height (inclusive) + end: Ending block height (inclusive) + include_tx: Whether to include transaction data (default: True) + """ + with session_scope() as session: + chain_id = get_chain_id(chain_id) + + blocks = session.exec( + select(Block).where( + Block.chain_id == chain_id, + Block.height >= start, + Block.height <= end, + ).order_by(Block.height.asc()) + ).all() + + result_blocks = [] + for b in blocks: + block_data = { + "height": b.height, + "hash": b.hash, + "parent_hash": b.parent_hash, + "proposer": b.proposer, + "timestamp": b.timestamp.isoformat(), + "tx_count": b.tx_count, + "state_root": b.state_root, + } + + if include_tx: + # Fetch transactions for this block + txs = session.exec( + select(Transaction) + .where(Transaction.chain_id == chain_id) + .where(Transaction.block_height == b.height) + ).all() + block_data["transactions"] = [tx.model_dump() for tx in txs] + + result_blocks.append(block_data) + + return { + "success": True, + "blocks": result_blocks, + "count": len(blocks), + } + + +@rate_limit(rate=50, per=60) +async def import_block( + request: Request, block_data: dict +) -> Dict[str, Any]: + """Import a block into the blockchain""" + global _last_import_time + + async with _import_lock: + try: + # Rate limiting: max 1 import per second + current_time = time.time() + time_since_last = current_time - _last_import_time + if time_since_last < 1.0: + await asyncio.sleep(1.0 - time_since_last) + + _last_import_time = time.time() + + chain_id = block_data.get("chain_id") or block_data.get("chainId") or get_chain_id(None) + block_hash = block_data["hash"] + + # Validate block hash format: must be 0x followed by exactly 64 hex characters + if not isinstance(block_hash, str) or not re.fullmatch(r"0x[0-9a-fA-F]{64}", block_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid block hash format") + + try: + block_height = int(block_data["height"]) + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid block height") from exc + + timestamp = block_data.get("timestamp") + if isinstance(timestamp, str): + try: + timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + except ValueError: + timestamp = datetime.now(timezone.utc) + elif timestamp is None: + timestamp = datetime.now(timezone.utc) + + with session_scope(chain_id) as session: + existing_height_block = session.exec( + select(Block) + .where(Block.chain_id == chain_id) + .where(Block.height == block_height) + ).first() + if existing_height_block is not None: + if existing_height_block.hash == block_hash: + return { + "success": True, + "block_height": existing_height_block.height, + "block_hash": existing_height_block.hash, + "chain_id": chain_id + } + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Block height {block_height} already exists with different hash", + ) + + # Validate parent block exists (skip for genesis block height 1) + parent_hash = block_data["parent_hash"] + if block_height > 1: + parent_block = session.exec( + select(Block).where(Block.hash == parent_hash) + ).first() + if parent_block is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Parent block not found", + ) + + # Check for hash conflicts across chains + existing_block = session.execute( + select(Block).where(Block.hash == block_hash) + ).first() + + if existing_block: + # Delete existing block with conflicting hash + _logger.warning(f"Deleting existing block with conflicting hash {block_hash} from chain {existing_block[0].chain_id}") + session.execute(delete(Block).where(Block.hash == block_hash)) + session.commit() + + # Create block + block = Block( + chain_id=chain_id, + height=block_height, + hash=block_hash, + parent_hash=block_data["parent_hash"], + proposer=block_data["proposer"], + timestamp=timestamp, + state_root=block_data.get("state_root"), + tx_count=block_data.get("tx_count", 0) + ) + session.add(block) + session.commit() + + return { + "success": True, + "block_height": block.height, + "block_hash": block.hash, + "chain_id": chain_id + } + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error importing block: {e}") + raise HTTPException(status_code=500, detail=f"Failed to import block: {str(e)}") diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/bridge.py b/apps/blockchain-node/src/aitbc_chain/rpc/bridge.py new file mode 100644 index 00000000..bd0caef1 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/bridge.py @@ -0,0 +1,201 @@ +""" +Bridge-related RPC endpoints. +""" + +from typing import Any, Dict, List +from fastapi import HTTPException, Request + +from ..logger import get_logger +from .utils import get_chain_id +from aitbc.rate_limiting import rate_limit + +_logger = get_logger(__name__) + + +@rate_limit(rate=20, per=60) +async def bridge_lock( + request: Request, + lock_data: dict +) -> Dict[str, Any]: + """ + Initiate a cross-chain bridge transfer by locking funds. + + This is step 1 of the atomic bridge: + 1. Lock funds on source chain (this endpoint) + 2. Generate proof + 3. Confirm on target chain + """ + try: + from ..cross_chain.bridge import get_cross_chain_bridge + bridge = get_cross_chain_bridge() + + if not bridge: + raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") + + source_chain = lock_data.get("source_chain", get_chain_id(None)) + target_chain = lock_data.get("target_chain") + sender = lock_data.get("sender") + recipient = lock_data.get("recipient") + amount = lock_data.get("amount", 0) + asset = lock_data.get("asset", "native") + + if not all([target_chain, sender, recipient]): + raise HTTPException(status_code=400, detail="Missing required fields: target_chain, sender, recipient") + + if amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + + # Execute lock + transfer = bridge.initiate_transfer( + source_chain=source_chain, + target_chain=target_chain, + sender=sender.lower(), + recipient=recipient.lower(), + amount=amount, + asset=asset + ) + + return { + "success": True, + "transfer_id": transfer.transfer_id, + "status": transfer.status.value, + "source_chain": source_chain, + "target_chain": target_chain, + "sender": sender, + "recipient": recipient, + "amount": amount, + "fee": (amount * 10) // 10000, # 0.1% fee + "lock_time": transfer.lock_time.isoformat() if transfer.lock_time else None, + "message": "Funds locked successfully. Use /bridge/confirm to complete." + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _logger.error(f"Bridge lock failed: {e}") + raise HTTPException(status_code=500, detail=f"Bridge lock failed: {str(e)}") + + +@rate_limit(rate=20, per=60) +async def bridge_confirm( + request: Request, + confirm_data: dict +) -> Dict[str, Any]: + """ + Confirm a cross-chain bridge transfer and release funds. + + This is step 2 of the atomic bridge: + 1. Validate proof of lock + 2. Release funds on target chain + 3. Mark transfer as complete + """ + try: + from ..cross_chain.bridge import get_cross_chain_bridge + bridge = get_cross_chain_bridge() + + if not bridge: + raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") + + transfer_id = confirm_data.get("transfer_id") + proof = confirm_data.get("proof") + + if not transfer_id or not proof: + raise HTTPException(status_code=400, detail="Missing required fields: transfer_id, proof") + + # Execute confirmation + transfer = bridge.confirm_transfer(transfer_id, proof) + + return { + "success": True, + "transfer_id": transfer.transfer_id, + "status": transfer.status.value, + "source_chain": transfer.source_chain, + "target_chain": transfer.target_chain, + "sender": transfer.sender, + "recipient": transfer.recipient, + "amount": transfer.amount, + "target_tx_hash": transfer.target_tx_hash, + "confirm_time": transfer.confirm_time.isoformat() if transfer.confirm_time else None, + "message": "Cross-chain transfer completed successfully" + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _logger.error(f"Bridge confirm failed: {e}") + raise HTTPException(status_code=500, detail=f"Bridge confirm failed: {str(e)}") + + +@rate_limit(rate=100, per=60) +async def get_bridge_transfer( + request: Request, + transfer_id: str +) -> Dict[str, Any]: + """Get the status of a cross-chain transfer""" + try: + from ..cross_chain.bridge import get_cross_chain_bridge + bridge = get_cross_chain_bridge() + + if not bridge: + raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") + + transfer = bridge.get_transfer(transfer_id) + if not transfer: + raise HTTPException(status_code=404, detail=f"Transfer {transfer_id} not found") + + return { + "success": True, + "transfer_id": transfer.transfer_id, + "status": transfer.status.value, + "source_chain": transfer.source_chain, + "target_chain": transfer.target_chain, + "sender": transfer.sender, + "recipient": transfer.recipient, + "amount": transfer.amount, + "asset": transfer.asset, + "source_tx_hash": transfer.source_tx_hash, + "target_tx_hash": transfer.target_tx_hash, + "lock_time": transfer.lock_time.isoformat() if transfer.lock_time else None, + "confirm_time": transfer.confirm_time.isoformat() if transfer.confirm_time else None + } + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Get bridge transfer failed: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get transfer: {str(e)}") + + +@rate_limit(rate=50, per=60) +async def list_pending_transfers( + request: Request, + chain_id: str = None +) -> List[Dict[str, Any]]: + """List all pending cross-chain transfers""" + try: + from ..cross_chain.bridge import get_cross_chain_bridge + bridge = get_cross_chain_bridge() + + if not bridge: + raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") + + chain_id = get_chain_id(chain_id) + transfers = bridge.list_pending_transfers(chain_id) + + return [ + { + "transfer_id": t.transfer_id, + "source_chain": t.source_chain, + "target_chain": t.target_chain, + "sender": t.sender, + "recipient": t.recipient, + "amount": t.amount, + "status": t.status.value, + "lock_time": t.lock_time.isoformat() if t.lock_time else None + } + for t in transfers + ] + + except Exception as e: + _logger.error(f"List pending transfers failed: {e}") + raise HTTPException(status_code=500, detail=f"Failed to list transfers: {str(e)}") diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/contracts.py b/apps/blockchain-node/src/aitbc_chain/rpc/contracts.py new file mode 100644 index 00000000..97f181da --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/contracts.py @@ -0,0 +1,205 @@ +""" +Contract-related RPC endpoints. +""" + +import time +from datetime import datetime, UTC +from typing import Any, Dict + +from fastapi import Request +from aitbc.rate_limiting import rate_limit + +from ..logger import get_logger + +_logger = get_logger(__name__) + +# Import contract services +from ..services.contract_service import contract_service +from ..services.messaging_contract import messaging_contract + + +@rate_limit(rate=50, per=60) +async def deploy_messaging_contract( + request: Request, deploy_data: dict +) -> Dict[str, Any]: + """Deploy the agent messaging contract to the blockchain""" + contract_address = "0xagent_messaging_001" + return {"success": True, "contract_address": contract_address, "status": "deployed"} + + +@rate_limit(rate=200, per=60) +async def list_contracts( + request: Request +) -> Dict[str, Any]: + """List all deployed contracts""" + return contract_service.list_contracts() + + +@rate_limit(rate=50, per=60) +async def deploy_contract( + request: Request, deploy_data: dict +) -> Dict[str, Any]: + """Deploy a new smart contract to the blockchain""" + contract_name = deploy_data.get("name") + contract_type = deploy_data.get("type", "zk-verifier") + + if not contract_name: + return {"success": False, "error": "Contract name is required"} + + # Generate a mock contract address for now + contract_address = f"0x{contract_name.lower()}_{int(time.time())}" + + return { + "success": True, + "contract_address": contract_address, + "name": contract_name, + "type": contract_type, + "status": "deployed", + "deployed_at": datetime.now(UTC).isoformat() + } + + +@rate_limit(rate=50, per=60) +async def call_contract( + request: Request, call_data: dict +) -> Dict[str, Any]: + """Call a method on a deployed contract""" + contract_address = call_data.get("address") + method = call_data.get("method") + params = call_data.get("params") + + if not contract_address: + return {"success": False, "error": "Contract address is required"} + if not method: + return {"success": False, "error": "Method name is required"} + + # Mock call result for now + return { + "success": True, + "result": f"Called {method} on {contract_address}", + "address": contract_address, + "method": method + } + + +@rate_limit(rate=50, per=60) +async def verify_contract( + request: Request, verify_data: dict +) -> Dict[str, Any]: + """Verify a ZK proof against a contract""" + contract_address = verify_data.get("address") + proof = verify_data.get("proof") + + if not contract_address: + return {"success": False, "error": "Contract address is required"} + + # Mock verification result for now + return { + "success": True, + "result": { + "valid": True, + "receipt_hash": "0xmock_receipt_hash", + "address": contract_address + } + } + + +@rate_limit(rate=200, per=60) +async def get_messaging_contract_state( + request: Request +) -> Dict[str, Any]: + """Get the current state of the messaging contract""" + state = { + "total_topics": len(messaging_contract.topics), + "total_messages": len(messaging_contract.messages), + "total_agents": len(messaging_contract.agent_reputations) + } + return {"success": True, "contract_state": state} + + +@rate_limit(rate=200, per=60) +async def get_forum_topics( + request: Request, limit: int = 50, offset: int = 0, sort_by: str = "last_activity" +) -> Dict[str, Any]: + """Get list of forum topics""" + return messaging_contract.get_topics(limit, offset, sort_by) + + +@rate_limit(rate=50, per=60) +async def create_forum_topic( + request: Request, topic_data: dict +) -> Dict[str, Any]: + """Create a new forum topic""" + return messaging_contract.create_topic( + topic_data.get("agent_id"), + topic_data.get("agent_address"), + topic_data.get("title"), + topic_data.get("description"), + topic_data.get("tags", []) + ) + + +@rate_limit(rate=200, per=60) +async def get_topic_messages( + request: Request, topic_id: str, limit: int = 50, offset: int = 0, sort_by: str = "timestamp" +) -> Dict[str, Any]: + """Get messages from a forum topic""" + return messaging_contract.get_messages(topic_id, limit, offset, sort_by) + + +@rate_limit(rate=50, per=60) +async def post_message( + request: Request, message_data: dict +) -> Dict[str, Any]: + """Post a message to a forum topic""" + return messaging_contract.post_message( + message_data.get("agent_id"), + message_data.get("agent_address"), + message_data.get("topic_id"), + message_data.get("content"), + message_data.get("message_type", "post"), + message_data.get("parent_message_id") + ) + + +@rate_limit(rate=50, per=60) +async def vote_message( + request: Request, message_id: str, vote_data: dict +) -> Dict[str, Any]: + """Vote on a message (upvote/downvote)""" + return messaging_contract.vote_message( + vote_data.get("agent_id"), + vote_data.get("agent_address"), + message_id, + vote_data.get("vote_type") + ) + + +@rate_limit(rate=200, per=60) +async def search_messages( + request: Request, query: str, limit: int = 50 +) -> Dict[str, Any]: + """Search messages by content""" + return messaging_contract.search_messages(query, limit) + + +@rate_limit(rate=200, per=60) +async def get_agent_reputation( + request: Request, agent_id: str +) -> Dict[str, Any]: + """Get agent reputation information""" + return messaging_contract.get_agent_reputation(agent_id) + + +@rate_limit(rate=50, per=60) +async def moderate_message( + request: Request, message_id: str, moderation_data: dict +) -> Dict[str, Any]: + """Moderate a message (moderator only)""" + return messaging_contract.moderate_message( + moderation_data.get("moderator_agent_id"), + moderation_data.get("moderator_address"), + message_id, + moderation_data.get("action"), + moderation_data.get("reason", "") + ) diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/disputes.py b/apps/blockchain-node/src/aitbc_chain/rpc/disputes.py new file mode 100644 index 00000000..e2b7e5f1 --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/disputes.py @@ -0,0 +1,336 @@ +""" +Dispute-related RPC endpoints. +""" + +from typing import Any, Dict, List +from fastapi import HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials + +from ..logger import get_logger +from .auth import get_authenticated_address + +_logger = get_logger(__name__) + +# Import dispute resolution service and models +from ..services.dispute_resolution import dispute_resolution_service +from ..models.dispute import ( + FileDisputeRequest, + FileDisputeResponse, + SubmitEvidenceRequest, + SubmitEvidenceResponse, + VerifyEvidenceRequest, + VerifyEvidenceResponse, + SubmitArbitrationVoteRequest, + SubmitArbitrationVoteResponse, + AuthorizeArbitratorRequest, + AuthorizeArbitratorResponse, + GetDisputeResponse, + GetEvidenceResponse, + GetArbitrationVotesResponse, +) + + +async def file_dispute( + request: FileDisputeRequest, + http_request: Request, + credentials: HTTPAuthorizationCredentials = None +) -> FileDisputeResponse: + """ + File a new dispute for a marketplace transaction. + This interacts with the DisputeResolution smart contract. + """ + try: + sender_address = get_authenticated_address(http_request, credentials) + + result = dispute_resolution_service.file_dispute( + agreement_id=request.agreement_id, + respondent=request.respondent, + dispute_type=request.dispute_type, + reason=request.reason, + evidence_hash=request.evidence_hash, + sender_address=sender_address + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to file dispute")) + + return FileDisputeResponse( + success=True, + dispute_id=result["dispute_id"], + status=result["status"], + message=result["message"] + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error filing dispute: {e}") + raise HTTPException(status_code=500, detail=f"Failed to file dispute: {str(e)}") + + +async def submit_evidence( + request: SubmitEvidenceRequest, + http_request: Request, + credentials: HTTPAuthorizationCredentials = None +) -> SubmitEvidenceResponse: + """ + Submit evidence for a dispute. + This interacts with the DisputeResolution smart contract. + """ + try: + submitter_address = get_authenticated_address(http_request, credentials) + + result = dispute_resolution_service.submit_evidence( + dispute_id=request.dispute_id, + evidence_type=request.evidence_type, + evidence_data=request.evidence_data, + submitter_address=submitter_address + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to submit evidence")) + + return SubmitEvidenceResponse( + success=True, + evidence_id=result["evidence_id"], + status=result["status"], + message=result["message"] + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error submitting evidence: {e}") + raise HTTPException(status_code=500, detail=f"Failed to submit evidence: {str(e)}") + + +async def verify_evidence( + request: VerifyEvidenceRequest, + http_request: Request, + credentials: HTTPAuthorizationCredentials = None +) -> VerifyEvidenceResponse: + """ + Verify evidence submitted in a dispute. + This can only be called by authorized arbitrators. + """ + try: + arbitrator_address = get_authenticated_address(http_request, credentials) + + result = dispute_resolution_service.verify_evidence( + dispute_id=request.dispute_id, + evidence_id=request.evidence_id, + is_valid=request.is_valid, + verification_score=request.verification_score, + arbitrator_address=arbitrator_address + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to verify evidence")) + + return VerifyEvidenceResponse( + success=True, + status=result["status"], + message=result["message"] + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error verifying evidence: {e}") + raise HTTPException(status_code=500, detail=f"Failed to verify evidence: {str(e)}") + + +async def submit_arbitration_vote( + request: SubmitArbitrationVoteRequest, + http_request: Request, + credentials: HTTPAuthorizationCredentials = None +) -> SubmitArbitrationVoteResponse: + """ + Submit an arbitration vote for a dispute. + This can only be called by authorized arbitrators assigned to the dispute. + """ + try: + arbitrator_address = get_authenticated_address(http_request, credentials) + + # Reject zero address in all modes - this is a sensitive arbitration operation + if arbitrator_address == "0x0000000000000000000000000000000000000000": + _logger.error("Vote submission attempted with zero address - rejected") + raise HTTPException( + status_code=401, + detail="Zero address is not allowed for arbitration operations" + ) + + return SubmitArbitrationVoteResponse( + success=True, + status="Submitted", + message=f"Vote submitted successfully for dispute {request.dispute_id}" + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error submitting arbitration vote: {e}") + raise HTTPException(status_code=500, detail=f"Failed to submit vote: {str(e)}") + + +async def authorize_arbitrator( + request: AuthorizeArbitratorRequest, + http_request: Request, + credentials: HTTPAuthorizationCredentials = None +) -> AuthorizeArbitratorResponse: + """ + Authorize a new arbitrator. + This can only be called by the contract owner. + """ + try: + owner_address = get_authenticated_address(http_request, credentials) + + result = dispute_resolution_service.authorize_arbitrator( + arbitrator_address=request.arbitrator, + reputation_score=request.reputation_score, + owner_address=owner_address + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to authorize arbitrator")) + + return AuthorizeArbitratorResponse( + success=True, + status=result["status"], + message=result["message"] + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error authorizing arbitrator: {e}") + raise HTTPException(status_code=500, detail=f"Failed to authorize arbitrator: {str(e)}") + + +async def get_active_disputes() -> Dict[str, Any]: + """ + Get all active disputes. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_active_disputes() + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get active disputes")) + + return result + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting active disputes: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get active disputes: {str(e)}") + + +async def get_authorized_arbitrators() -> Dict[str, Any]: + """ + Get all authorized arbitrators. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_authorized_arbitrators() + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get authorized arbitrators")) + + return result + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting authorized arbitrators: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get authorized arbitrators: {str(e)}") + + +async def get_arbitrator_disputes(arbitrator_address: str) -> Dict[str, Any]: + """ + Get all disputes assigned to an arbitrator. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_arbitrator_disputes(arbitrator_address) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get arbitrator disputes")) + + return result + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting arbitrator disputes: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get arbitrator disputes: {str(e)}") + + +async def get_user_disputes(user_address: str) -> Dict[str, Any]: + """ + Get all disputes for a specific user. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_user_disputes(user_address) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get user disputes")) + + return result + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting user disputes: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get user disputes: {str(e)}") + + +async def get_dispute(dispute_id: int) -> GetDisputeResponse: + """ + Get details of a specific dispute. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_dispute(dispute_id) + + if not result.get("success"): + raise HTTPException(status_code=404, detail=result.get("error", "Dispute not found")) + + dispute_data = result["dispute"] + return GetDisputeResponse(**dispute_data) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting dispute: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get dispute: {str(e)}") + + +async def get_dispute_evidence(dispute_id: int) -> List[GetEvidenceResponse]: + """ + Get all evidence submitted for a dispute. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_dispute_evidence(dispute_id) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get dispute evidence")) + + return [GetEvidenceResponse(**e) for e in result["evidence"]] + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting dispute evidence: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get dispute evidence: {str(e)}") + + +async def get_arbitration_votes(dispute_id: int) -> List[GetArbitrationVotesResponse]: + """ + Get all arbitration votes for a dispute. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_arbitration_votes(dispute_id) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get arbitration votes")) + + return [GetArbitrationVotesResponse(**v) for v in result["votes"]] + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting arbitration votes: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get arbitration votes: {str(e)}") diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/gossip.py b/apps/blockchain-node/src/aitbc_chain/rpc/gossip.py new file mode 100644 index 00000000..1e1898cb --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/gossip.py @@ -0,0 +1,96 @@ +""" +Gossip-related RPC endpoints. +""" + +from typing import List, Optional +from fastapi import Request +from pydantic import BaseModel, Field +from sqlmodel import select + +from ..database import session_scope +from ..models import Receipt +from ..logger import get_logger +from .utils import get_chain_id +from aitbc.rate_limiting import rate_limit + +_logger = get_logger(__name__) + + +class GetLogsRequest(BaseModel): + """Request model for eth_getLogs RPC endpoint.""" + address: Optional[str] = Field(None, description="Contract address to filter logs") + from_block: Optional[int] = Field(None, description="Starting block height") + to_block: Optional[int] = Field(None, description="Ending block height") + topics: Optional[List[str]] = Field(None, description="Event topics to filter") + + +class LogEntry(BaseModel): + """Single log entry from smart contract event.""" + address: str + topics: List[str] + data: str + block_number: int + transaction_hash: str + log_index: int + + +class GetLogsResponse(BaseModel): + """Response model for eth_getLogs RPC endpoint.""" + logs: List[LogEntry] + count: int + + +@rate_limit(rate=200, per=60) +async def get_logs( + request: Request, + logs_request: GetLogsRequest, + chain_id: Optional[str] = None +) -> GetLogsResponse: + """ + Query smart contract event logs using eth_getLogs-compatible endpoint. + Filters Receipt model for logs matching contract address and event topics. + """ + chain_id = get_chain_id(chain_id) + + with session_scope() as session: + # Build query for receipts + query = select(Receipt).where(Receipt.chain_id == chain_id) + + # Filter by block range + if logs_request.from_block is not None: + query = query.where(Receipt.block_height >= logs_request.from_block) + if logs_request.to_block is not None: + query = query.where(Receipt.block_height <= logs_request.to_block) + + # Execute query + receipts = session.execute(query).scalars().all() + + logs = [] + for receipt in receipts: + # Extract event logs from receipt payload + payload = receipt.payload or {} + events = payload.get("events", []) + + for event in events: + # Filter by contract address if specified + if logs_request.address and event.get("address") != logs_request.address: + continue + + # Filter by topics if specified + if logs_request.topics: + event_topics = event.get("topics", []) + if not any(topic in event_topics for topic in logs_request.topics): + continue + + # Create log entry + log_entry = LogEntry( + address=event.get("address", ""), + topics=event.get("topics", []), + data=str(event.get("data", "")), + block_number=receipt.block_height or 0, + transaction_hash=receipt.receipt_id, + log_index=event.get("logIndex", 0) + ) + logs.append(log_entry) + + return GetLogsResponse(logs=logs, count=len(logs)) diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/islands.py b/apps/blockchain-node/src/aitbc_chain/rpc/islands.py new file mode 100644 index 00000000..d3bdf83c --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/islands.py @@ -0,0 +1,199 @@ +""" +Island-related RPC endpoints. +""" + +from typing import Any, Dict +from fastapi import HTTPException +from pydantic import BaseModel + +from ..logger import get_logger +from ..services.island_manager import get_island_manager + +_logger = get_logger(__name__) + + +class JoinIslandRequest(BaseModel): + """Request model for joining an island""" + island_id: str + island_name: str + chain_id: str + role: str = "compute-provider" + is_hub: bool = False + + +class JoinIslandResponse(BaseModel): + """Response model for joining an island""" + success: bool + island_id: str + status: str + message: str + + +class LeaveIslandRequest(BaseModel): + """Request model for leaving an island""" + island_id: str + + +class LeaveIslandResponse(BaseModel): + """Response model for leaving an island""" + success: bool + island_id: str + status: str + message: str + + +class BridgeRequestRequest(BaseModel): + """Request model for requesting a bridge""" + target_island_id: str + + +class BridgeRequestResponse(BaseModel): + """Response model for bridge request""" + success: bool + request_id: str + target_island_id: str + status: str + message: str + + +async def join_island(request: JoinIslandRequest) -> JoinIslandResponse: + """ + Join an island for edge compute operations. + Calls IslandManager.join_island to register the node as a member of the specified island. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + success = island_manager.join_island( + island_id=request.island_id, + island_name=request.island_name, + chain_id=request.chain_id, + is_hub=request.is_hub + ) + + if success: + return JoinIslandResponse( + success=True, + island_id=request.island_id, + status="joined", + message=f"Successfully joined island {request.island_id}" + ) + else: + return JoinIslandResponse( + success=False, + island_id=request.island_id, + status="failed", + message=f"Failed to join island {request.island_id} (may already be a member)" + ) + + +async def leave_island(request: LeaveIslandRequest) -> LeaveIslandResponse: + """ + Leave an island. + Calls IslandManager.leave_island to remove the node from the specified island. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + success = island_manager.leave_island(request.island_id) + + if success: + return LeaveIslandResponse( + success=True, + island_id=request.island_id, + status="left", + message=f"Successfully left island {request.island_id}" + ) + else: + return LeaveIslandResponse( + success=False, + island_id=request.island_id, + status="failed", + message=f"Failed to leave island {request.island_id} (may not be a member)" + ) + + +async def list_islands() -> Dict[str, Any]: + """ + List all islands that the node is a member of. + Calls IslandManager.get_all_islands to retrieve island memberships. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + islands = island_manager.get_all_islands() + + return { + "islands": [ + { + "island_id": island.island_id, + "island_name": island.island_name, + "chain_id": island.chain_id, + "status": island.status.value, + "role": getattr(island, 'role', 'unknown'), + "peer_count": island.peer_count, + "is_hub": island.is_hub, + "joined_at": island.joined_at + } + for island in islands + ], + "total": len(islands) + } + + +async def get_island(island_id: str) -> Dict[str, Any]: + """ + Get details about a specific island. + Calls IslandManager.get_island_info to retrieve island membership details. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + island = island_manager.get_island_info(island_id) + + if island is None: + raise HTTPException(status_code=404, detail=f"Island {island_id} not found") + + return { + "island_id": island.island_id, + "island_name": island.island_name, + "chain_id": island.chain_id, + "status": island.status.value, + "role": getattr(island, 'role', 'unknown'), + "peer_count": island.peer_count, + "is_hub": island.is_hub, + "joined_at": island.joined_at + } + + +async def request_bridge(request: BridgeRequestRequest) -> BridgeRequestResponse: + """ + Request a bridge to another island for cross-island communication. + Calls IslandManager.request_bridge to initiate a bridge request. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + request_id = island_manager.request_bridge(request.target_island_id) + + if request_id: + return BridgeRequestResponse( + success=True, + request_id=request_id, + target_island_id=request.target_island_id, + status="pending", + message=f"Bridge request {request_id} submitted for {request.target_island_id}" + ) + else: + return BridgeRequestResponse( + success=False, + request_id="", + target_island_id=request.target_island_id, + status="failed", + message=f"Failed to request bridge to {request.target_island_id} (may already be a member)" + ) diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/router.py b/apps/blockchain-node/src/aitbc_chain/rpc/router.py index 836d85a9..6c03a94f 100644 --- a/apps/blockchain-node/src/aitbc_chain/rpc/router.py +++ b/apps/blockchain-node/src/aitbc_chain/rpc/router.py @@ -1,12 +1,8 @@ from __future__ import annotations import asyncio -import hashlib import json -import os -import re import time -import uuid from typing import Any, Dict, Optional, List from datetime import datetime, timezone, timedelta @@ -22,594 +18,218 @@ from ..metrics import metrics_registry from ..models import Account, Block, Receipt, Transaction from ..logger import get_logger from ..sync import ChainSync -from ..contracts.agent_messaging_contract import messaging_contract -from .contract_service import contract_service -from .dispute_resolution_service import dispute_resolution_service -from ..network.island_manager import get_island_manager +from .auth import get_authenticated_address +from .utils import ( + set_poa_proposer, + get_poa_proposer, + get_chain_id, + validate_chain_id, + get_supported_chains, + get_chain_db, + normalize_transaction_data, +) from aitbc.rate_limiting import rate_limit +# Import domain modules +from .blocks import ( + get_genesis_allocations, + get_head, + get_block, + get_blocks_range, + import_block, +) +from .transactions import ( + submit_transaction, + get_mempool, + submit_marketplace_transaction, + query_transactions, + TransactionRequest, +) +from .accounts import ( + get_account, + get_account_alias, + get_account_details, + create_account, + faucet_request, + get_balance_breakdown, + reconcile_balance, +) +from .disputes import ( + file_dispute, + submit_evidence, + verify_evidence, + submit_arbitration_vote, + authorize_arbitrator, + get_active_disputes, + get_authorized_arbitrators, + get_arbitrator_disputes, + get_user_disputes, + get_dispute, + get_dispute_evidence, + get_arbitration_votes, +) +from ..models.dispute import ( + FileDisputeRequest, + FileDisputeResponse, + SubmitEvidenceRequest, + SubmitEvidenceResponse, + VerifyEvidenceRequest, + VerifyEvidenceResponse, + SubmitArbitrationVoteRequest, + SubmitArbitrationVoteResponse, + AuthorizeArbitratorRequest, + AuthorizeArbitratorResponse, + GetDisputeResponse, + GetEvidenceResponse, + GetArbitrationVotesResponse, +) +from .contracts import ( + deploy_messaging_contract, + list_contracts, + deploy_contract, + call_contract, + verify_contract, + get_messaging_contract_state, + get_forum_topics, + create_forum_topic, + get_topic_messages, + post_message, + vote_message, + search_messages, + get_agent_reputation, + moderate_message, +) +from .sync import ( + export_chain, + import_chain, + force_sync, +) +from .gossip import ( + get_logs, + GetLogsRequest, + GetLogsResponse, +) +from .islands import ( + join_island, + leave_island, + list_islands, + get_island, + request_bridge, + JoinIslandRequest, + JoinIslandResponse, + LeaveIslandRequest, + LeaveIslandResponse, + BridgeRequestRequest, + BridgeRequestResponse, +) +from .bridge import ( + bridge_lock, + bridge_confirm, + get_bridge_transfer, + list_pending_transfers, +) +from .staking import ( + stake_tokens, + unstake_tokens, + get_staking_info, +) + _logger = get_logger(__name__) # Security scheme for authentication security = HTTPBearer(auto_error=False) -def get_authenticated_address(request: Request, credentials: Optional[HTTPAuthorizationCredentials] = None) -> str: - """ - Extract authenticated wallet address from request headers or JWT token. - - Priority order: - 1. X-Wallet-Address header (for API key auth) - 2. JWT Bearer token (if provided) - 3. Development mode fallback (if DEV_MODE=true) - - Returns: - str: The authenticated wallet address - - Raises: - HTTPException: If authentication fails and not in development mode - """ - # Check for X-Wallet-Address header (API key authentication) - wallet_address = request.headers.get("X-Wallet-Address") - if wallet_address: - if not wallet_address.startswith("0x") or len(wallet_address) != 42: - _logger.warning(f"Invalid wallet address format in X-Wallet-Address header: {wallet_address}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid wallet address format" - ) - if os.getenv("TRUST_X_WALLET_ADDRESS", "false").lower() != "true": - _logger.warning("Rejected untrusted X-Wallet-Address header") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="X-Wallet-Address header is not trusted without explicit server configuration" - ) - _logger.debug(f"Authenticated via X-Wallet-Address header: {wallet_address}") - return wallet_address - - # Check for JWT Bearer token - if credentials and credentials.scheme == "Bearer": - _logger.warning("JWT authentication attempted but not supported") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="JWT authentication is not supported. Use X-Wallet-Address header with TRUST_X_WALLET_ADDRESS=true for trusted internal requests." - ) - - # Development mode fallback - if os.getenv("DEV_MODE", "false").lower() == "true": - _logger.warning("Rejected unauthenticated request in development mode") - - # No valid authentication found - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required. Provide X-Wallet-Address header or valid JWT token.", - headers={"WWW-Authenticate": "Bearer"} - ) - router = APIRouter() # Global rate limiter for importBlock _last_import_time = 0 _import_lock = asyncio.Lock() -# Global variable to store the PoA proposer -_poa_proposers: Dict[str, Any] = {} - -def set_poa_proposer(proposer, chain_id: str = None): - """Set the global PoA proposer instance""" - if chain_id is None: - chain_id = getattr(getattr(proposer, "_config", None), "chain_id", None) or get_chain_id(None) - _poa_proposers[chain_id] = proposer - -def get_poa_proposer(chain_id: str = None): - """Get the global PoA proposer instance""" - chain_id = get_chain_id(chain_id) - return _poa_proposers.get(chain_id) - -def get_chain_id(chain_id: str = None) -> str: - """Get chain_id from parameter or use default from settings""" - if chain_id is None: - from ..config import settings - return settings.chain_id or "ait-mainnet" - return chain_id - -def validate_chain_id(chain_id: str) -> bool: - """Validate that chain_id is in supported_chains list""" - from ..config import settings - supported_chains = [c.strip() for c in settings.supported_chains.split(",")] - return chain_id in supported_chains - -def get_supported_chains() -> List[str]: - from ..config import settings - chains = [chain.strip() for chain in settings.supported_chains.split(",") if chain.strip()] - if not chains and settings.chain_id: - return [settings.chain_id] - return chains - -def get_chain_db(chain_id: str = None): - """Get chain-specific database engine""" - resolved_chain_id = get_chain_id(chain_id) - if not validate_chain_id(resolved_chain_id): - raise HTTPException(status_code=400, detail=f"Chain {resolved_chain_id} not in supported_chains") - return get_engine(resolved_chain_id) - -def _normalize_transaction_data(tx_data: Dict[str, Any], chain_id: str) -> Dict[str, Any]: - sender = tx_data.get("from") - recipient = tx_data.get("to") - if not isinstance(sender, str) or not sender.strip(): - raise ValueError("transaction.from is required") - if not isinstance(recipient, str) or not recipient.strip(): - raise ValueError("transaction.to is required") - - try: - amount = int(tx_data["amount"]) - except KeyError as exc: - raise ValueError("transaction.amount is required") from exc - except (TypeError, ValueError) as exc: - raise ValueError("transaction.amount must be an integer") from exc - - try: - fee = int(tx_data.get("fee", 10)) - except (TypeError, ValueError) as exc: - raise ValueError("transaction.fee must be an integer") from exc - - try: - nonce = int(tx_data.get("nonce", 0)) - except (TypeError, ValueError) as exc: - raise ValueError("transaction.nonce must be an integer") from exc - - if amount < 0: - raise ValueError("transaction.amount must be non-negative") - if fee < 0: - raise ValueError("transaction.fee must be non-negative") - if nonce < 0: - raise ValueError("transaction.nonce must be non-negative") - - payload = tx_data.get("payload", {}) - if payload is None: - payload = {} - - tx_type = tx_data.get("type", "TRANSFER") - if tx_type: - tx_type = tx_type.upper() - - # Ensure payload is a dict - if isinstance(payload, str): - try: - import json - payload = json.loads(payload) - except Exception: - payload = {} - - if not isinstance(payload, dict): - payload = {} - - return { - "chain_id": chain_id, - "type": tx_type, - "from": sender.strip(), - "to": recipient.strip(), - "amount": amount, - "value": amount, # Add value field for state transition compatibility - "fee": fee, - "nonce": nonce, - "payload": payload, - "signature": tx_data.get("signature") or tx_data.get("sig"), - } - -def _validate_transaction_admission(tx_data: Dict[str, Any], mempool: Any) -> None: - from ..mempool import compute_tx_hash - - chain_id = tx_data["chain_id"] - supported_chains = get_supported_chains() - if not chain_id: - raise ValueError("transaction.chain_id is required") - if supported_chains and chain_id not in supported_chains: - raise ValueError(f"unsupported chain_id '{chain_id}'. Supported chains: {supported_chains}") - - tx_hash = compute_tx_hash(tx_data) - - with session_scope() as session: - sender_account = session.get(Account, (chain_id, tx_data["from"])) - if sender_account is None: - raise ValueError(f"sender account not found on chain '{chain_id}'") - - total_cost = tx_data["amount"] + tx_data["fee"] - if sender_account.balance < total_cost: - raise ValueError( - f"insufficient balance for sender '{tx_data['from']}' on chain '{chain_id}': has {sender_account.balance}, needs {total_cost}" - ) - - if tx_data["nonce"] != sender_account.nonce: - raise ValueError( - f"invalid nonce for sender '{tx_data['from']}' on chain '{chain_id}': expected {sender_account.nonce}, got {tx_data['nonce']}" - ) - - existing_tx = session.exec( - select(Transaction) - .where(Transaction.chain_id == chain_id) - .where(Transaction.tx_hash == tx_hash) - ).first() - if existing_tx is not None: - raise ValueError(f"transaction '{tx_hash}' is already confirmed on chain '{chain_id}'") - - existing_nonce = session.exec( - select(Transaction) - .where(Transaction.chain_id == chain_id) - .where(Transaction.sender == tx_data["from"]) - .where(Transaction.nonce == tx_data["nonce"]) - ).first() - if existing_nonce is not None: - raise ValueError( - f"sender '{tx_data['from']}' already used nonce {tx_data['nonce']} on chain '{chain_id}'" - ) - - pending_txs = mempool.list_transactions(chain_id=chain_id) - if any(pending_tx.tx_hash == tx_hash for pending_tx in pending_txs): - raise ValueError(f"transaction '{tx_hash}' is already pending on chain '{chain_id}'") - if any( - pending_tx.content.get("from") == tx_data["from"] and pending_tx.content.get("nonce") == tx_data["nonce"] - for pending_tx in pending_txs - ): - raise ValueError( - f"sender '{tx_data['from']}' already has pending nonce {tx_data['nonce']} on chain '{chain_id}'" - ) - -def _serialize_receipt(receipt: Receipt) -> Dict[str, Any]: - return { - "receipt_id": receipt.receipt_id, - "job_id": receipt.job_id, - "payload": receipt.payload, - "miner_signature": receipt.miner_signature, - "coordinator_attestations": receipt.coordinator_attestations, - "minted_amount": receipt.minted_amount, - "recorded_at": receipt.recorded_at.isoformat(), - } - - -class TransactionRequest(BaseModel): - model_config = {"populate_by_name": True} - - type: str = Field(description="Transaction type, e.g. TRANSFER or RECEIPT_CLAIM") - sender: str = Field(alias="from") - nonce: int - fee: int = Field(ge=0) - payload: Dict[str, Any] - sig: Optional[str] = Field(default=None, description="Signature payload") - chain_id: Optional[str] = None - - @model_validator(mode="after") - def normalize_type(self) -> "TransactionRequest": # type: ignore[override] - normalized = self.type.upper() - if normalized not in {"TRANSFER", "RECEIPT_CLAIM"}: - raise ValueError(f"unsupported transaction type: {self.type}") - self.type = normalized - - # Support both payload shapes during migration: - # - {"recipient": "...", "amount": ...} - # - {"to": "...", "value": ...} - if self.type == "TRANSFER": - recipient = self.payload.get("recipient") or self.payload.get("to") - if not recipient: - raise ValueError("transfer payload requires 'recipient' (or legacy 'to')") - self.payload["recipient"] = recipient - self.payload.setdefault("to", recipient) - - if "amount" not in self.payload and "value" in self.payload: - self.payload["amount"] = self.payload["value"] - if "value" not in self.payload and "amount" in self.payload: - self.payload["value"] = self.payload["amount"] - - return self - - -class ReceiptSubmissionRequest(BaseModel): - sender: str - nonce: int - fee: int = Field(ge=0) - payload: Dict[str, Any] - sig: Optional[str] = None - - -class EstimateFeeRequest(BaseModel): - type: Optional[str] = None - payload: Dict[str, Any] = Field(default_factory=dict) - +# ============================================================================ +# BLOCK ENDPOINTS +# ============================================================================ @router.get("/genesis_allocations", summary="Get genesis allocations from blockchain") @rate_limit(rate=200, per=60) -async def get_genesis_allocations( +async def get_genesis_allocations_route( request: Request, chain_id: str = None ) -> Dict[str, Any]: """Get genesis allocations from genesis block metadata for RPC bootstrap""" - chain_id = get_chain_id(chain_id) - - with session_scope(chain_id) as session: - # Get genesis block (height 0) - genesis = session.exec( - select(Block).where(Block.chain_id == chain_id).where(Block.height == 0) - ).first() - - if not genesis: - raise HTTPException(status_code=404, detail=f"Genesis block not found for chain {chain_id}") - - # Extract allocations from block metadata - if not genesis.block_metadata: - raise HTTPException(status_code=404, detail=f"Genesis block metadata not found for chain {chain_id}") - - try: - metadata = json.loads(genesis.block_metadata) - allocations = metadata.get("allocations", []) - return { - "chain_id": chain_id, - "allocations": allocations, - "genesis_hash": genesis.hash, - "genesis_height": genesis.height, - "genesis_state_root": genesis.state_root, # Include the actual genesis state_root - } - except json.JSONDecodeError as e: - raise HTTPException(status_code=500, detail=f"Failed to parse genesis block metadata: {e}") + return await get_genesis_allocations(request, chain_id) @router.get("/head", summary="Get current chain head") @rate_limit(rate=200, per=60) -async def get_head( +async def get_head_route( request: Request, chain_id: str = None ) -> Dict[str, Any]: """Get current chain head""" - chain_id = get_chain_id(chain_id) - - metrics_registry.increment("rpc_get_head_total") - start = time.perf_counter() - with session_scope(chain_id) as session: - result = session.exec(select(Block).where(Block.chain_id == chain_id).order_by(Block.height.desc()).limit(1)).first() - if result is None: - metrics_registry.increment("rpc_get_head_not_found_total") - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no blocks yet") - metrics_registry.increment("rpc_get_head_success_total") - metrics_registry.observe("rpc_get_head_duration_seconds", time.perf_counter() - start) - return { - "height": result.height, - "hash": result.hash, - "timestamp": result.timestamp.isoformat(), - "tx_count": result.tx_count, - } + return await get_head(request, chain_id) @router.get("/blocks/{height}", summary="Get block by height") @rate_limit(rate=200, per=60) -async def get_block( +async def get_block_route( request: Request, height: int, chain_id: str = None ) -> Dict[str, Any]: """Get block by height""" - chain_id = get_chain_id(chain_id) - - metrics_registry.increment("rpc_get_block_total") - start = time.perf_counter() - with session_scope(chain_id) as session: - block = session.exec( - select(Block).where(Block.chain_id == chain_id).where(Block.height == height) - ).first() - if block is None: - metrics_registry.increment("rpc_get_block_not_found_total") - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="block not found") - metrics_registry.increment("rpc_get_block_success_total") - - txs = session.exec( - select(Transaction) - .where(Transaction.chain_id == chain_id) - .where(Transaction.block_height == height) - ).all() - tx_list = [] - for tx in txs: - t = dict(tx.payload) if tx.payload else {} - t["tx_hash"] = tx.tx_hash - tx_list.append(t) - - metrics_registry.observe("rpc_get_block_duration_seconds", time.perf_counter() - start) - return { - "chain_id": block.chain_id, - "height": block.height, - "hash": block.hash, - "parent_hash": block.parent_hash, - "proposer": block.proposer, - "timestamp": block.timestamp.isoformat(), - "tx_count": block.tx_count, - "state_root": block.state_root, - "transactions": tx_list, - } + return await get_block(request, height, chain_id) +@router.get("/blocks-range", summary="Get blocks in height range") +@rate_limit(rate=200, per=60) +async def get_blocks_range_route( + request: Request, start: int = 0, end: int = 10, include_tx: bool = True, chain_id: str = None +) -> Dict[str, Any]: + """Get blocks in a height range""" + return await get_blocks_range(request, start, end, include_tx, chain_id) + + +@router.post("/importBlock", summary="Import a block") +@rate_limit(rate=50, per=60) +async def import_block_route( + request: Request, block_data: dict +) -> Dict[str, Any]: + """Import a block into the blockchain""" + return await import_block(request, block_data) + + +# ============================================================================ +# TRANSACTION ENDPOINTS +# ============================================================================ + @router.post("/transaction", summary="Submit transaction") @rate_limit(rate=50, per=60) -async def submit_transaction( +async def submit_transaction_route( request: Request, tx_data: TransactionRequest ) -> Dict[str, Any]: """Submit a new transaction to the mempool""" - from ..mempool import get_mempool - - try: - mempool = get_mempool() - chain_id = get_chain_id(None) - - # Convert TransactionRequest to dict for normalization - # Model validator already normalized payload, so use 'to' directly from payload - tx_data_dict = { - "from": tx_data.sender, - "to": tx_data.payload.get("to"), # Model validator sets this from recipient/to - "amount": tx_data.payload.get("amount", tx_data.payload.get("value", 0)), - "fee": tx_data.fee, - "nonce": tx_data.nonce, - "payload": tx_data.payload, - "type": tx_data.type, - "signature": tx_data.sig - } - - tx_data_dict = _normalize_transaction_data(tx_data_dict, chain_id) - _validate_transaction_admission(tx_data_dict, mempool) - - tx_hash = mempool.add(tx_data_dict, chain_id=chain_id) - - return { - "success": True, - "transaction_hash": tx_hash, - "message": "Transaction submitted to mempool" - } - except Exception as e: - _logger.error("Failed to submit transaction", extra={"error": str(e)}) - raise HTTPException(status_code=400, detail=f"Failed to submit transaction: {str(e)}") + return await submit_transaction(request, tx_data) @router.get("/mempool", summary="Get pending transactions") @rate_limit(rate=200, per=60) -async def get_mempool( +async def get_mempool_route( request: Request, chain_id: str = None, limit: int = 100 ) -> Dict[str, Any]: """Get pending transactions from mempool""" - from ..mempool import get_mempool - - try: - mempool = get_mempool() - pending_txs = mempool.get_pending_transactions(chain_id=chain_id, limit=limit) - - return { - "success": True, - "transactions": pending_txs, - "count": len(pending_txs) - } - except Exception as e: - _logger.error(f"Failed to get mempool", extra={"error": str(e)}) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get mempool: {str(e)}") - - -@router.get("/account/{address}", summary="Get account information") -@rate_limit(rate=200, per=60) -async def get_account( - request: Request, address: str, chain_id: str = None -) -> Dict[str, Any]: - """Get account information""" - chain_id = get_chain_id(chain_id) - - with session_scope() as session: - account = session.exec(select(Account).where(Account.address == address).where(Account.chain_id == chain_id)).first() - if not account: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") - - return { - "address": account.address, - "balance": account.balance, - "nonce": account.nonce, - "chain_id": account.chain_id - } - - -@router.get("/accounts/{address}", summary="Get account information (alias)") -@rate_limit(rate=200, per=60) -async def get_account_alias( - request: Request, address: str, chain_id: str = None -) -> Dict[str, Any]: - """Get account information (alias endpoint)""" - return await get_account(address, chain_id) + return await get_mempool(request, chain_id, limit) @router.post("/transactions/marketplace", summary="Submit marketplace transaction") @rate_limit(rate=50, per=60) -async def submit_marketplace_transaction( +async def submit_marketplace_transaction_route( request: Request, tx_data: Dict[str, Any] ) -> Dict[str, Any]: - """Submit a marketplace purchase transaction to the blockchain""" - from ..config import settings as cfg - chain_id = get_chain_id(tx_data.get("chain_id")) - - metrics_registry.increment("rpc_marketplace_transaction_total") - start = time.perf_counter() - - try: - with session_scope() as session: - # Validate sender account - sender_addr = tx_data.get("from") - sender_account = session.get(Account, (chain_id, sender_addr)) - if not sender_account: - raise ValueError(f"Sender account not found: {sender_addr}") - - # Validate balance - amount = tx_data.get("value", 0) - fee = tx_data.get("fee", 0) - total_cost = amount + fee - - if sender_account.balance < total_cost: - raise ValueError(f"Insufficient balance: {sender_account.balance} < {total_cost}") - - # Validate nonce - tx_nonce = tx_data.get("nonce", 0) - if tx_nonce != sender_account.nonce: - raise ValueError(f"Invalid nonce: expected {sender_account.nonce}, got {tx_nonce}") - - # Get or create recipient account - recipient_addr = tx_data.get("to") - recipient_account = session.get(Account, (chain_id, recipient_addr)) - if not recipient_account: - recipient_account = Account( - chain_id=chain_id, - address=recipient_addr, - balance=0, - nonce=0 - ) - session.add(recipient_account) - - # Create transaction record - tx_hash = compute_tx_hash(tx_data) - transaction = Transaction( - chain_id=chain_id, - tx_hash=tx_hash, - sender=sender_addr, - recipient=recipient_addr, - payload=tx_data.get("payload", {}), - created_at=datetime.now(timezone.utc), - nonce=tx_nonce, - value=amount, - fee=fee, - status="pending", - timestamp=datetime.now(timezone.utc).isoformat() - ) - session.add(transaction) - - # Update account balances (pending state) - sender_account.balance -= total_cost - sender_account.nonce += 1 - recipient_account.balance += amount - - metrics_registry.increment("rpc_marketplace_transaction_success") - duration = time.perf_counter() - start - metrics_registry.observe("rpc_marketplace_transaction_duration_seconds", duration) - - _logger.info(f"Marketplace transaction submitted: {tx_hash[:16]}... from {sender_addr[:16]}... to {recipient_addr[:16]}... amount={amount}") - - return { - "success": True, - "tx_hash": tx_hash, - "status": "pending", - "chain_id": chain_id, - "amount": amount, - "fee": fee, - "from": sender_addr, - "to": recipient_addr - } - - except ValueError as e: - metrics_registry.increment("rpc_marketplace_transaction_validation_errors_total") - _logger.error(f"Marketplace transaction validation failed: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - metrics_registry.increment("rpc_marketplace_transaction_errors_total") - _logger.error(f"Failed to submit marketplace transaction", extra={"error": str(e)}) - raise HTTPException(status_code=500, detail=f"Failed to submit marketplace transaction: {str(e)}") + """Submit a marketplace transaction""" + return await submit_marketplace_transaction(request, tx_data) @router.get("/transactions", summary="Query transactions") @rate_limit(rate=200, per=60) -async def query_transactions( +async def query_transactions_route( request: Request, transaction_type: Optional[str] = None, island_id: Optional[str] = None, @@ -620,2111 +240,457 @@ async def query_transactions( chain_id: str = None ) -> List[Dict[str, Any]]: """Query transactions with optional filters""" - chain_id = get_chain_id(chain_id) - - with session_scope() as session: - query = select(Transaction).where(Transaction.chain_id == chain_id) - - # Apply filters based on payload fields - transactions = session.exec(query).all() - - results = [] - for tx in transactions: - # Filter by transaction type in payload - if transaction_type and tx.payload.get('type') != transaction_type: - continue - - # Filter by island_id in payload - if island_id and tx.payload.get('island_id') != island_id: - continue - - # Filter by pair in payload - if pair and tx.payload.get('pair') != pair: - continue - - # Filter by status in payload - if status and tx.payload.get('status') != status: - continue - - # Filter by order_id in payload - if order_id and tx.payload.get('order_id') != order_id and tx.payload.get('offer_id') != order_id and tx.payload.get('bid_id') != order_id: - continue - - results.append({ - "transaction_id": tx.id, - "tx_hash": tx.tx_hash, - "sender": tx.sender, - "recipient": tx.recipient, - "payload": tx.payload, - "status": tx.status, - "created_at": tx.created_at.isoformat(), - "timestamp": tx.timestamp, - "nonce": tx.nonce, - "value": tx.value, - "fee": tx.fee - }) - - # Apply limit - if limit: - results = results[:limit] - - return results - - -@router.get("/blocks-range", summary="Get blocks in height range") -@rate_limit(rate=200, per=60) -async def get_blocks_range( - request: Request, start: int = 0, end: int = 10, include_tx: bool = True, chain_id: str = None -) -> Dict[str, Any]: - """Get blocks in a height range - - Args: - start: Starting block height (inclusive) - end: Ending block height (inclusive) - include_tx: Whether to include transaction data (default: True) - """ - with session_scope() as session: - from ..models import Transaction - chain_id = get_chain_id(chain_id) - - blocks = session.exec( - select(Block).where( - Block.chain_id == chain_id, - Block.height >= start, - Block.height <= end, - ).order_by(Block.height.asc()) - ).all() - - result_blocks = [] - for b in blocks: - block_data = { - "height": b.height, - "hash": b.hash, - "parent_hash": b.parent_hash, - "proposer": b.proposer, - "timestamp": b.timestamp.isoformat(), - "tx_count": b.tx_count, - "state_root": b.state_root, - } - - if include_tx: - # Fetch transactions for this block - txs = session.exec( - select(Transaction) - .where(Transaction.chain_id == chain_id) - .where(Transaction.block_height == b.height) - ).all() - block_data["transactions"] = [tx.model_dump() for tx in txs] - - result_blocks.append(block_data) - - return { - "success": True, - "blocks": result_blocks, - "count": len(blocks), - } - -@router.post("/contracts/deploy/messaging", summary="Deploy messaging contract") -@rate_limit(rate=50, per=60) -async def deploy_messaging_contract( - request: Request, deploy_data: dict -) -> Dict[str, Any]: - """Deploy the agent messaging contract to the blockchain""" - contract_address = "0xagent_messaging_001" - return {"success": True, "contract_address": contract_address, "status": "deployed"} - -@router.get("/contracts", summary="List deployed contracts") -@rate_limit(rate=200, per=60) -async def list_contracts( - request: Request -) -> Dict[str, Any]: - """List all deployed contracts""" - return contract_service.list_contracts() - -@router.post("/contracts/deploy", summary="Deploy a smart contract") -@rate_limit(rate=50, per=60) -async def deploy_contract( - request: Request, deploy_data: dict -) -> Dict[str, Any]: - """Deploy a new smart contract to the blockchain""" - contract_name = deploy_data.get("name") - contract_type = deploy_data.get("type", "zk-verifier") - - if not contract_name: - return {"success": False, "error": "Contract name is required"} - - # Generate a mock contract address for now - contract_address = f"0x{contract_name.lower()}_{int(time.time())}" - - return { - "success": True, - "contract_address": contract_address, - "name": contract_name, - "type": contract_type, - "status": "deployed", - "deployed_at": datetime.now(UTC).isoformat() - } - -@router.post("/contracts/call", summary="Call a contract method") -@rate_limit(rate=50, per=60) -async def call_contract( - request: Request, call_data: dict -) -> Dict[str, Any]: - """Call a method on a deployed contract""" - contract_address = call_data.get("address") - method = call_data.get("method") - params = call_data.get("params") - - if not contract_address: - return {"success": False, "error": "Contract address is required"} - if not method: - return {"success": False, "error": "Method name is required"} - - # Mock call result for now - return { - "success": True, - "result": f"Called {method} on {contract_address}", - "address": contract_address, - "method": method - } - -@router.post("/contracts/verify", summary="Verify a ZK proof") -@rate_limit(rate=50, per=60) -async def verify_contract( - request: Request, verify_data: dict -) -> Dict[str, Any]: - """Verify a ZK proof against a contract""" - contract_address = verify_data.get("address") - proof = verify_data.get("proof") - - if not contract_address: - return {"success": False, "error": "Contract address is required"} - - # Mock verification result for now - return { - "success": True, - "result": { - "valid": True, - "receipt_hash": "0xmock_receipt_hash", - "address": contract_address - } - } - -@router.get("/contracts/messaging/state", summary="Get messaging contract state") -@rate_limit(rate=200, per=60) -async def get_messaging_contract_state( - request: Request -) -> Dict[str, Any]: - """Get the current state of the messaging contract""" - state = { - "total_topics": len(messaging_contract.topics), - "total_messages": len(messaging_contract.messages), - "total_agents": len(messaging_contract.agent_reputations) - } - return {"success": True, "contract_state": state} - -@router.get("/messaging/topics", summary="Get forum topics") -@rate_limit(rate=200, per=60) -async def get_forum_topics( - request: Request, limit: int = 50, offset: int = 0, sort_by: str = "last_activity" -) -> Dict[str, Any]: - """Get list of forum topics""" - return messaging_contract.get_topics(limit, offset, sort_by) - -@router.post("/messaging/topics/create", summary="Create forum topic") -@rate_limit(rate=50, per=60) -async def create_forum_topic( - request: Request, topic_data: dict -) -> Dict[str, Any]: - """Create a new forum topic""" - return messaging_contract.create_topic( - topic_data.get("agent_id"), - topic_data.get("agent_address"), - topic_data.get("title"), - topic_data.get("description"), - topic_data.get("tags", []) + return await query_transactions( + request, transaction_type, island_id, pair, status, order_id, limit, chain_id ) -@router.get("/messaging/topics/{topic_id}/messages", summary="Get topic messages") + +# ============================================================================ +# ACCOUNT ENDPOINTS +# ============================================================================ + +@router.get("/account/{address}", summary="Get account information") @rate_limit(rate=200, per=60) -async def get_topic_messages( - request: Request, topic_id: str, limit: int = 50, offset: int = 0, sort_by: str = "timestamp" +async def get_account_route( + request: Request, address: str, chain_id: str = None ) -> Dict[str, Any]: - """Get messages from a forum topic""" - return messaging_contract.get_messages(topic_id, limit, offset, sort_by) + """Get account information""" + return await get_account(request, address, chain_id) -@router.post("/messaging/messages/post", summary="Post message") -@rate_limit(rate=50, per=60) -async def post_message( - request: Request, message_data: dict -) -> Dict[str, Any]: - """Post a message to a forum topic""" - return messaging_contract.post_message( - message_data.get("agent_id"), - message_data.get("agent_address"), - message_data.get("topic_id"), - message_data.get("content"), - message_data.get("message_type", "post"), - message_data.get("parent_message_id") - ) -@router.post("/messaging/messages/{message_id}/vote", summary="Vote on message") -@rate_limit(rate=50, per=60) -async def vote_message( - request: Request, message_id: str, vote_data: dict -) -> Dict[str, Any]: - """Vote on a message (upvote/downvote)""" - return messaging_contract.vote_message( - vote_data.get("agent_id"), - vote_data.get("agent_address"), - message_id, - vote_data.get("vote_type") - ) - -@router.get("/messaging/messages/search", summary="Search messages") +@router.get("/accounts/{address}", summary="Get account information (alias)") @rate_limit(rate=200, per=60) -async def search_messages( - request: Request, query: str, limit: int = 50 +async def get_account_alias_route( + request: Request, address: str, chain_id: str = None ) -> Dict[str, Any]: - """Search messages by content""" - return messaging_contract.search_messages(query, limit) - -@router.get("/messaging/agents/{agent_id}/reputation", summary="Get agent reputation") -@rate_limit(rate=200, per=60) -async def get_agent_reputation( - request: Request, agent_id: str -) -> Dict[str, Any]: - """Get agent reputation information""" - return messaging_contract.get_agent_reputation(agent_id) - -@router.post("/messaging/messages/{message_id}/moderate", summary="Moderate message") -@rate_limit(rate=50, per=60) -async def moderate_message( - request: Request, message_id: str, moderation_data: dict -) -> Dict[str, Any]: - """Moderate a message (moderator only)""" - return messaging_contract.moderate_message( - moderation_data.get("moderator_agent_id"), - moderation_data.get("moderator_address"), - message_id, - moderation_data.get("action"), - moderation_data.get("reason", "") - ) - -@router.post("/importBlock", summary="Import a block") -@rate_limit(rate=50, per=60) -async def import_block( - request: Request, block_data: dict -) -> Dict[str, Any]: - """Import a block into the blockchain""" - global _last_import_time - - async with _import_lock: - try: - # Rate limiting: max 1 import per second - current_time = time.time() - time_since_last = current_time - _last_import_time - if time_since_last < 1.0: - await asyncio.sleep(1.0 - time_since_last) - - _last_import_time = time.time() - - chain_id = block_data.get("chain_id") or block_data.get("chainId") or get_chain_id(None) - block_hash = block_data["hash"] - - # Validate block hash format: must be 0x followed by exactly 64 hex characters - if not isinstance(block_hash, str) or not re.fullmatch(r"0x[0-9a-fA-F]{64}", block_hash): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid block hash format") - - try: - block_height = int(block_data["height"]) - except (KeyError, TypeError, ValueError) as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid block height") from exc - - timestamp = block_data.get("timestamp") - if isinstance(timestamp, str): - try: - timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) - except ValueError: - timestamp = datetime.now(timezone.utc) - elif timestamp is None: - timestamp = datetime.now(timezone.utc) - - with session_scope(chain_id) as session: - existing_height_block = session.exec( - select(Block) - .where(Block.chain_id == chain_id) - .where(Block.height == block_height) - ).first() - if existing_height_block is not None: - if existing_height_block.hash == block_hash: - return { - "success": True, - "block_height": existing_height_block.height, - "block_hash": existing_height_block.hash, - "chain_id": chain_id - } - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Block height {block_height} already exists with different hash", - ) - - # Validate parent block exists (skip for genesis block height 1) - parent_hash = block_data["parent_hash"] - if block_height > 1: - parent_block = session.exec( - select(Block).where(Block.hash == parent_hash) - ).first() - if parent_block is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Parent block not found", - ) - - # Check for hash conflicts across chains - existing_block = session.execute( - select(Block).where(Block.hash == block_hash) - ).first() - - if existing_block: - # Delete existing block with conflicting hash - _logger.warning(f"Deleting existing block with conflicting hash {block_hash} from chain {existing_block[0].chain_id}") - session.execute(delete(Block).where(Block.hash == block_hash)) - session.commit() - - # Create block - block = Block( - chain_id=chain_id, - height=block_height, - hash=block_hash, - parent_hash=block_data["parent_hash"], - proposer=block_data["proposer"], - timestamp=timestamp, - state_root=block_data.get("state_root"), - tx_count=block_data.get("tx_count", 0) - ) - session.add(block) - session.commit() - - return { - "success": True, - "block_height": block.height, - "block_hash": block.hash, - "chain_id": chain_id - } - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error importing block: {e}") - raise HTTPException(status_code=500, detail=f"Failed to import block: {str(e)}") - -def _serialize_optional_timestamp(value: Any) -> Optional[str]: - if value is None: - return None - if isinstance(value, str): - return value - if hasattr(value, "isoformat"): - return value.isoformat() - return str(value) - -def _parse_datetime_value(value: Any, field_name: str) -> Optional[datetime]: - if value in (None, ""): - return None - if isinstance(value, datetime): - return value - if isinstance(value, str): - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError as exc: - raise HTTPException(status_code=400, detail=f"Invalid {field_name}: {value}") from exc - raise HTTPException(status_code=400, detail=f"Invalid {field_name} type: {type(value).__name__}") - -def _select_export_blocks(session, chain_id: str) -> List[Block]: - blocks_result = session.execute( - select(Block) - .where(Block.chain_id == chain_id) - .order_by(Block.height.asc(), Block.id.desc()) - ) - blocks: List[Block] = [] - seen_heights = set() - duplicate_count = 0 - for block in blocks_result.scalars().all(): - if block.height in seen_heights: - duplicate_count += 1 - continue - seen_heights.add(block.height) - blocks.append(block) - if duplicate_count: - _logger.warning(f"Filtered {duplicate_count} duplicate exported blocks for chain {chain_id}") - return blocks - -def _dedupe_import_blocks(blocks: List[Dict[str, Any]], chain_id: str) -> List[Dict[str, Any]]: - latest_by_height: Dict[int, Dict[str, Any]] = {} - duplicate_count = 0 - for block_data in blocks: - if "height" not in block_data: - raise HTTPException(status_code=400, detail="Block height is required") - try: - height = int(block_data["height"]) - except (TypeError, ValueError) as exc: - raise HTTPException(status_code=400, detail=f"Invalid block height: {block_data.get('height')}") from exc - block_chain_id = block_data.get("chain_id") - if block_chain_id and block_chain_id != chain_id: - raise HTTPException( - status_code=400, - detail=f"Mismatched block chain_id '{block_chain_id}' for import chain '{chain_id}'", - ) - normalized_block = dict(block_data) - normalized_block["height"] = height - normalized_block["chain_id"] = chain_id - if height in latest_by_height: - duplicate_count += 1 - latest_by_height[height] = normalized_block - if duplicate_count: - _logger.warning(f"Filtered {duplicate_count} duplicate imported blocks for chain {chain_id}") - return [latest_by_height[height] for height in sorted(latest_by_height)] - -@router.get("/export-chain", summary="Export full chain state") -@rate_limit(rate=200, per=60) -async def export_chain( - request: Request, chain_id: str = None -) -> Dict[str, Any]: - """Export full chain state as JSON for manual synchronization""" - chain_id = get_chain_id(chain_id) - try: - # Use session_scope for database operations - with session_scope() as session: - blocks = _select_export_blocks(session, chain_id) - - accounts_result = session.execute( - select(Account) - .where(Account.chain_id == chain_id) - .order_by(Account.address) - ) - accounts = list(accounts_result.scalars().all()) - - txs_result = session.execute( - select(Transaction) - .where(Transaction.chain_id == chain_id) - .order_by(Transaction.block_height, Transaction.id) - ) - transactions = list(txs_result.scalars().all()) - - # Build export data - export_data = { - "chain_id": chain_id, - "export_timestamp": datetime.now().isoformat(), - "block_count": len(blocks), - "account_count": len(accounts), - "transaction_count": len(transactions), - "blocks": [ - { - "chain_id": b.chain_id, - "height": b.height, - "hash": b.hash, - "parent_hash": b.parent_hash, - "proposer": b.proposer, - "timestamp": b.timestamp.isoformat() if b.timestamp else None, - "state_root": b.state_root, - "tx_count": b.tx_count, - "block_metadata": b.block_metadata, - } - for b in blocks - ], - "accounts": [ - { - "chain_id": a.chain_id, - "address": a.address, - "balance": a.balance, - "nonce": a.nonce - } - for a in accounts - ], - "transactions": [ - { - "id": t.id, - "chain_id": t.chain_id, - "tx_hash": t.tx_hash, - "block_height": t.block_height, - "sender": t.sender, - "recipient": t.recipient, - "payload": t.payload, - "value": t.value, - "fee": t.fee, - "nonce": t.nonce, - "timestamp": _serialize_optional_timestamp(t.timestamp), - "status": t.status, - "created_at": t.created_at.isoformat() if t.created_at else None, - "tx_metadata": t.tx_metadata, - } - for t in transactions - ] - } - - return { - "success": True, - "export_data": export_data, - "export_size_bytes": len(json.dumps(export_data)) - } - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error exporting chain: {e}") - raise HTTPException(status_code=500, detail=f"Failed to export chain: {str(e)}") - -@router.post("/import-chain", summary="Import chain state") -@rate_limit(rate=50, per=60) -async def import_chain( - request: Request, import_data: dict -) -> Dict[str, Any]: - """Import chain state from JSON for manual synchronization""" - async with _import_lock: - try: - chain_id = import_data.get("chain_id") - blocks = import_data.get("blocks", []) - accounts = import_data.get("accounts", []) - transactions = import_data.get("transactions", []) - - if not chain_id and blocks: - chain_id = blocks[0].get("chain_id") - chain_id = get_chain_id(chain_id) - - unique_blocks = _dedupe_import_blocks(blocks, chain_id) - - with session_scope() as session: - if not unique_blocks: - raise HTTPException(status_code=400, detail="No blocks to import") - - existing_blocks = session.execute( - select(Block) - .where(Block.chain_id == chain_id) - .order_by(Block.height) - ) - existing_count = len(list(existing_blocks.scalars().all())) - - if existing_count > 0: - _logger.info(f"Backing up existing chain with {existing_count} blocks") - - _logger.info(f"Clearing existing transactions for chain {chain_id}") - session.execute(delete(Transaction).where(Transaction.chain_id == chain_id)) - if accounts: - _logger.info(f"Clearing existing accounts for chain {chain_id}") - session.execute(delete(Account).where(Account.chain_id == chain_id)) - _logger.info(f"Clearing existing blocks for chain {chain_id}") - session.execute(delete(Block).where(Block.chain_id == chain_id)) - - import_hashes = {block_data["hash"] for block_data in unique_blocks} - if import_hashes: - hash_conflict_result = session.execute( - select(Block.hash, Block.chain_id) - .where(Block.hash.in_(import_hashes)) - ) - hash_conflicts = hash_conflict_result.all() - if hash_conflicts: - conflict_chains = {chain_id for _, chain_id in hash_conflicts} - _logger.warning(f"Clearing {len(hash_conflicts)} blocks with conflicting hashes across chains: {conflict_chains}") - session.execute(delete(Block).where(Block.hash.in_(import_hashes))) - - session.commit() - session.expire_all() - - _logger.info(f"Importing {len(unique_blocks)} unique blocks (filtered from {len(blocks)} total)") - - for block_data in unique_blocks: - block_timestamp = _parse_datetime_value(block_data.get("timestamp"), "block timestamp") or datetime.now(timezone.utc) - block = Block( - chain_id=chain_id, - height=block_data["height"], - hash=block_data["hash"], - parent_hash=block_data["parent_hash"], - proposer=block_data["proposer"], - timestamp=block_timestamp, - state_root=block_data.get("state_root"), - tx_count=block_data.get("tx_count", 0), - block_metadata=block_data.get("block_metadata"), - ) - session.add(block) - - for account_data in accounts: - account_chain_id = account_data.get("chain_id", chain_id) - if account_chain_id != chain_id: - raise HTTPException( - status_code=400, - detail=f"Mismatched account chain_id '{account_chain_id}' for import chain '{chain_id}'", - ) - account = Account( - chain_id=account_chain_id, - address=account_data["address"], - balance=account_data["balance"], - nonce=account_data["nonce"], - ) - session.add(account) - - for tx_data in transactions: - tx_chain_id = tx_data.get("chain_id", chain_id) - if tx_chain_id != chain_id: - raise HTTPException( - status_code=400, - detail=f"Mismatched transaction chain_id '{tx_chain_id}' for import chain '{chain_id}'", - ) - tx = Transaction( - id=tx_data.get("id"), - chain_id=tx_chain_id, - tx_hash=str(tx_data.get("tx_hash") or tx_data.get("id") or ""), - block_height=tx_data.get("block_height"), - sender=tx_data["sender"], - recipient=tx_data["recipient"], - payload=tx_data.get("payload", {}), - value=tx_data.get("value", 0), - fee=tx_data.get("fee", 0), - nonce=tx_data.get("nonce", 0), - timestamp=_serialize_optional_timestamp(tx_data.get("timestamp")), - status=tx_data.get("status", "pending"), - tx_metadata=tx_data.get("tx_metadata"), - ) - created_at = _parse_datetime_value(tx_data.get("created_at"), "transaction created_at") - if created_at is not None: - tx.created_at = created_at - session.add(tx) - - session.commit() - - return { - "success": True, - "imported_blocks": len(unique_blocks), - "imported_accounts": len(accounts), - "imported_transactions": len(transactions), - "chain_id": chain_id, - "message": f"Successfully imported {len(unique_blocks)} blocks", - } - - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error importing chain: {e}") - raise HTTPException(status_code=500, detail=f"Failed to import chain: {str(e)}") - -@router.post("/force-sync", summary="Force reorg to specified peer") -@rate_limit(rate=50, per=60) -async def force_sync( - request: Request, peer_data: dict -) -> Dict[str, Any]: - """Force blockchain reorganization to sync with specified peer""" - try: - peer_url = peer_data.get("peer_url") - target_height = peer_data.get("target_height") - - if not peer_url: - raise HTTPException(status_code=400, detail="peer_url is required") - - # Validate peer_url to prevent SSRF - import re - from urllib.parse import urlparse - - parsed = urlparse(peer_url) - if not parsed.scheme or parsed.scheme not in ['http', 'https']: - raise HTTPException(status_code=400, detail="Invalid URL scheme") - - # Block private/internal IPs - hostname = parsed.hostname - if hostname: - # Block localhost and private IP ranges - if hostname in ['localhost', '127.0.0.1', '::1'] or hostname.startswith('192.168.') or hostname.startswith('10.') or hostname.startswith('172.16.'): - raise HTTPException(status_code=400, detail="Invalid peer URL") - - import requests - - response = requests.get(f"{peer_url}/rpc/export-chain", timeout=30) - - if response.status_code != 200: - raise HTTPException(status_code=400, detail=f"Failed to fetch peer chain: {response.status_code}") - - peer_chain_data = response.json() - peer_blocks = peer_chain_data["export_data"]["blocks"] - - if target_height and len(peer_blocks) < target_height: - raise HTTPException(status_code=400, detail=f"Peer only has {len(peer_blocks)} blocks, cannot sync to height {target_height}") - - import_result = await import_chain(peer_chain_data["export_data"]) - - return { - "success": True, - "synced_from": peer_url, - "synced_blocks": import_result["imported_blocks"], - "target_height": target_height or import_result["imported_blocks"], - "message": f"Successfully synced with peer {peer_url}" - } - - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error forcing sync: {e}") - raise HTTPException(status_code=500, detail=f"Failed to force sync: {str(e)}") - - -class GetLogsRequest(BaseModel): - """Request model for eth_getLogs RPC endpoint.""" - address: Optional[str] = Field(None, description="Contract address to filter logs") - from_block: Optional[int] = Field(None, description="Starting block height") - to_block: Optional[int] = Field(None, description="Ending block height") - topics: Optional[List[str]] = Field(None, description="Event topics to filter") - - -class LogEntry(BaseModel): - """Single log entry from smart contract event.""" - address: str - topics: List[str] - data: str - block_number: int - transaction_hash: str - log_index: int - - -class GetLogsResponse(BaseModel): - """Response model for eth_getLogs RPC endpoint.""" - logs: List[LogEntry] - count: int - - -@router.post("/eth_getLogs", summary="Query smart contract event logs") -@rate_limit(rate=200, per=60) -async def get_logs( - request: Request, - logs_request: GetLogsRequest, - chain_id: Optional[str] = None -) -> GetLogsResponse: - """ - Query smart contract event logs using eth_getLogs-compatible endpoint. - Filters Receipt model for logs matching contract address and event topics. - """ - chain_id = get_chain_id(chain_id) - - with session_scope() as session: - # Build query for receipts - query = select(Receipt).where(Receipt.chain_id == chain_id) - - # Filter by block range - if request.from_block is not None: - query = query.where(Receipt.block_height >= request.from_block) - if request.to_block is not None: - query = query.where(Receipt.block_height <= request.to_block) - - # Execute query - receipts = session.execute(query).scalars().all() - - logs = [] - for receipt in receipts: - # Extract event logs from receipt payload - payload = receipt.payload or {} - events = payload.get("events", []) - - for event in events: - # Filter by contract address if specified - if request.address and event.get("address") != request.address: - continue - - # Filter by topics if specified - if request.topics: - event_topics = event.get("topics", []) - if not any(topic in event_topics for topic in request.topics): - continue - - # Create log entry - log_entry = LogEntry( - address=event.get("address", ""), - topics=event.get("topics", []), - data=str(event.get("data", "")), - block_number=receipt.block_height or 0, - transaction_hash=receipt.receipt_id, - log_index=event.get("logIndex", 0) - ) - logs.append(log_entry) - - return GetLogsResponse(logs=logs, count=len(logs)) - - -# Island Management Endpoints for Edge API -class JoinIslandRequest(BaseModel): - """Request model for joining an island""" - island_id: str - island_name: str - chain_id: str - role: str = "compute-provider" - is_hub: bool = False - - -class JoinIslandResponse(BaseModel): - """Response model for joining an island""" - success: bool - island_id: str - status: str - message: str - - -class LeaveIslandRequest(BaseModel): - """Request model for leaving an island""" - island_id: str - - -class LeaveIslandResponse(BaseModel): - """Response model for leaving an island""" - success: bool - island_id: str - status: str - message: str - - -class BridgeRequestRequest(BaseModel): - """Request model for requesting a bridge""" - target_island_id: str - - -class BridgeRequestResponse(BaseModel): - """Response model for bridge request""" - success: bool - request_id: str - target_island_id: str - status: str - message: str - - -# Dispute Resolution Endpoints -class FileDisputeRequest(BaseModel): - """Request model for filing a dispute""" - agreement_id: int = Field(description="ID of the agreement being disputed") - respondent: str = Field(description="Address of the respondent") - dispute_type: str = Field(description="Type of dispute (Performance, Payment, ServiceQuality, Availability, Other)") - reason: str = Field(description="Reason for the dispute") - evidence_hash: str = Field(description="Hash of initial evidence") - - -class FileDisputeResponse(BaseModel): - """Response model for filing a dispute""" - success: bool - dispute_id: int - status: str - message: str - - -class SubmitEvidenceRequest(BaseModel): - """Request model for submitting evidence""" - dispute_id: int = Field(description="ID of the dispute") - evidence_type: str = Field(description="Type of evidence") - evidence_data: str = Field(description="Evidence data (IPFS hash, URL, etc.)") - - -class SubmitEvidenceResponse(BaseModel): - """Response model for submitting evidence""" - success: bool - evidence_id: int - status: str - message: str - - -class VerifyEvidenceRequest(BaseModel): - """Request model for verifying evidence""" - dispute_id: int = Field(description="ID of the dispute") - evidence_id: int = Field(description="ID of the evidence") - is_valid: bool = Field(description="Whether the evidence is valid") - verification_score: int = Field(description="Verification score (0-100)") - - -class VerifyEvidenceResponse(BaseModel): - """Response model for verifying evidence""" - success: bool - status: str - message: str - - -class SubmitArbitrationVoteRequest(BaseModel): - """Request model for submitting arbitration vote""" - dispute_id: int = Field(description="ID of the dispute") - vote_in_favor_of_initiator: bool = Field(description="Vote for initiator") - confidence: int = Field(description="Confidence level (0-100)") - reasoning: str = Field(description="Reasoning for the vote") - - -class SubmitArbitrationVoteResponse(BaseModel): - """Response model for submitting arbitration vote""" - success: bool - status: str - message: str - - -class AuthorizeArbitratorRequest(BaseModel): - """Request model for authorizing an arbitrator""" - arbitrator: str = Field(description="Address of the arbitrator") - reputation_score: int = Field(description="Initial reputation score") - - -class AuthorizeArbitratorResponse(BaseModel): - """Response model for authorizing an arbitrator""" - success: bool - status: str - message: str - - -class GetDisputeResponse(BaseModel): - """Response model for getting dispute details""" - dispute_id: int - agreement_id: int - initiator: str - respondent: str - status: str - dispute_type: str - reason: str - evidence_hash: str - filing_time: int - evidence_deadline: int - arbitration_deadline: int - resolution_amount: int - winner: str - resolution_reason: str - arbitrator_count: int - is_escalated: bool - escalation_level: int - - -class GetEvidenceResponse(BaseModel): - """Response model for getting dispute evidence""" - evidence_id: int - dispute_id: int - submitter: str - evidence_type: str - evidence_data: str - evidence_hash: str - submission_time: int - is_valid: bool - verification_score: int - verified_by: str - - -class GetArbitrationVotesResponse(BaseModel): - """Response model for getting arbitration votes""" - dispute_id: int - arbitrator: str - vote_in_favor_of_initiator: bool - confidence: int - reasoning: str - vote_time: int - is_valid: bool - - -@router.post("/disputes/file", summary="File a new dispute") -async def file_dispute( - request: FileDisputeRequest, - http_request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) -) -> FileDisputeResponse: - """ - File a new dispute for a marketplace transaction. - This interacts with the DisputeResolution smart contract. - """ - try: - # Get authenticated address from request - sender_address = get_authenticated_address(http_request, credentials) - - # Use dispute resolution service - result = dispute_resolution_service.file_dispute( - agreement_id=request.agreement_id, - respondent=request.respondent, - dispute_type=request.dispute_type, - reason=request.reason, - evidence_hash=request.evidence_hash, - sender_address=sender_address - ) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to file dispute")) - - return FileDisputeResponse( - success=True, - dispute_id=result["dispute_id"], - status=result["status"], - message=result["message"] - ) - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error filing dispute: {e}") - raise HTTPException(status_code=500, detail=f"Failed to file dispute: {str(e)}") - - -@router.post("/disputes/evidence", summary="Submit evidence for a dispute") -async def submit_evidence( - request: SubmitEvidenceRequest, - http_request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) -) -> SubmitEvidenceResponse: - """ - Submit evidence for a dispute. - This interacts with the DisputeResolution smart contract. - """ - try: - # Get authenticated address from request - submitter_address = get_authenticated_address(http_request, credentials) - - result = dispute_resolution_service.submit_evidence( - dispute_id=request.dispute_id, - evidence_type=request.evidence_type, - evidence_data=request.evidence_data, - submitter_address=submitter_address - ) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to submit evidence")) - - return SubmitEvidenceResponse( - success=True, - evidence_id=result["evidence_id"], - status=result["status"], - message=result["message"] - ) - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error submitting evidence: {e}") - raise HTTPException(status_code=500, detail=f"Failed to submit evidence: {str(e)}") - - -@router.post("/disputes/verify-evidence", summary="Verify evidence (arbitrator only)") -async def verify_evidence( - request: VerifyEvidenceRequest, - http_request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) -) -> VerifyEvidenceResponse: - """ - Verify evidence submitted in a dispute. - This can only be called by authorized arbitrators. - """ - try: - # Get authenticated address from request - arbitrator_address = get_authenticated_address(http_request, credentials) - - result = dispute_resolution_service.verify_evidence( - dispute_id=request.dispute_id, - evidence_id=request.evidence_id, - is_valid=request.is_valid, - verification_score=request.verification_score, - arbitrator_address=arbitrator_address - ) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to verify evidence")) - - return VerifyEvidenceResponse( - success=True, - status=result["status"], - message=result["message"] - ) - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error verifying evidence: {e}") - raise HTTPException(status_code=500, detail=f"Failed to verify evidence: {str(e)}") - - -@router.post("/disputes/vote", summary="Submit arbitration vote (arbitrator only)") -async def submit_arbitration_vote( - request: SubmitArbitrationVoteRequest, - http_request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) -) -> SubmitArbitrationVoteResponse: - """ - Submit an arbitration vote for a dispute. - This can only be called by authorized arbitrators assigned to the dispute. - """ - try: - # Get authenticated address from request - arbitrator_address = get_authenticated_address(http_request, credentials) - - # Reject zero address in all modes - this is a sensitive arbitration operation - if arbitrator_address == "0x0000000000000000000000000000000000000000": - _logger.error("Vote submission attempted with zero address - rejected") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Zero address is not allowed for arbitration operations" - ) - - return SubmitArbitrationVoteResponse( - success=True, - status="Submitted", - message=f"Vote submitted successfully for dispute {request.dispute_id}" - ) - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error submitting arbitration vote: {e}") - raise HTTPException(status_code=500, detail=f"Failed to submit vote: {str(e)}") - - -@router.post("/disputes/arbitrators/authorize", summary="Authorize an arbitrator (admin only)") -async def authorize_arbitrator( - request: AuthorizeArbitratorRequest, - http_request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) -) -> AuthorizeArbitratorResponse: - """ - Authorize a new arbitrator. - This can only be called by the contract owner. - """ - try: - # Get authenticated address from request - owner_address = get_authenticated_address(http_request, credentials) - - result = dispute_resolution_service.authorize_arbitrator( - arbitrator_address=request.arbitrator, - reputation_score=request.reputation_score, - owner_address=owner_address - ) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to authorize arbitrator")) - - return AuthorizeArbitratorResponse( - success=True, - status=result["status"], - message=result["message"] - ) - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error authorizing arbitrator: {e}") - raise HTTPException(status_code=500, detail=f"Failed to authorize arbitrator: {str(e)}") - - -@router.get("/disputes/active", summary="Get all active disputes") -async def get_active_disputes() -> Dict[str, Any]: - """ - Get all active disputes. - This retrieves information from the DisputeResolution smart contract. - """ - try: - result = dispute_resolution_service.get_active_disputes() - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to get active disputes")) - - return result - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error getting active disputes: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get active disputes: {str(e)}") - - -@router.get("/disputes/arbitrators", summary="Get all authorized arbitrators") -async def get_authorized_arbitrators() -> Dict[str, Any]: - """ - Get all authorized arbitrators. - This retrieves information from the DisputeResolution smart contract. - """ - try: - result = dispute_resolution_service.get_authorized_arbitrators() - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to get authorized arbitrators")) - - return result - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error getting authorized arbitrators: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get authorized arbitrators: {str(e)}") - - -@router.get("/disputes/arbitrators/{arbitrator_address}", summary="Get disputes for an arbitrator") -async def get_arbitrator_disputes(arbitrator_address: str) -> Dict[str, Any]: - """ - Get all disputes assigned to an arbitrator. - This retrieves information from the DisputeResolution smart contract. - """ - try: - result = dispute_resolution_service.get_arbitrator_disputes(arbitrator_address) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to get arbitrator disputes")) - - return result - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error getting arbitrator disputes: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get arbitrator disputes: {str(e)}") - - -@router.get("/disputes/user/{user_address}", summary="Get disputes for a user") -async def get_user_disputes(user_address: str) -> Dict[str, Any]: - """ - Get all disputes for a specific user. - This retrieves information from the DisputeResolution smart contract. - """ - try: - result = dispute_resolution_service.get_user_disputes(user_address) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to get user disputes")) - - return result - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error getting user disputes: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get user disputes: {str(e)}") - - -@router.get("/disputes/{dispute_id}", summary="Get dispute details") -async def get_dispute(dispute_id: int) -> GetDisputeResponse: - """ - Get details of a specific dispute. - This retrieves information from the DisputeResolution smart contract. - """ - try: - result = dispute_resolution_service.get_dispute(dispute_id) - - if not result.get("success"): - raise HTTPException(status_code=404, detail=result.get("error", "Dispute not found")) - - dispute_data = result["dispute"] - return GetDisputeResponse(**dispute_data) - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error getting dispute: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get dispute: {str(e)}") - - -@router.get("/disputes/{dispute_id}/evidence", summary="Get evidence for a dispute") -async def get_dispute_evidence(dispute_id: int) -> List[GetEvidenceResponse]: - """ - Get all evidence submitted for a dispute. - This retrieves information from the DisputeResolution smart contract. - """ - try: - result = dispute_resolution_service.get_dispute_evidence(dispute_id) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to get dispute evidence")) - - return [GetEvidenceResponse(**e) for e in result["evidence"]] - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error getting dispute evidence: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get dispute evidence: {str(e)}") - - -@router.get("/disputes/{dispute_id}/votes", summary="Get arbitration votes for a dispute") -async def get_arbitration_votes(dispute_id: int) -> List[GetArbitrationVotesResponse]: - """ - Get all arbitration votes for a dispute. - This retrieves information from the DisputeResolution smart contract. - """ - try: - result = dispute_resolution_service.get_arbitration_votes(dispute_id) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Failed to get arbitration votes")) - - return [GetArbitrationVotesResponse(**v) for v in result["votes"]] - except HTTPException: - raise - except Exception as e: - _logger.error(f"Error getting arbitration votes: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get arbitration votes: {str(e)}") - - -@router.post("/islands/join", summary="Join an island") -async def join_island(request: JoinIslandRequest) -> JoinIslandResponse: - """ - Join an island for edge compute operations. - Calls IslandManager.join_island to register the node as a member of the specified island. - """ - island_manager = get_island_manager() - if island_manager is None: - raise HTTPException(status_code=503, detail="Island manager not available") - - success = island_manager.join_island( - island_id=request.island_id, - island_name=request.island_name, - chain_id=request.chain_id, - is_hub=request.is_hub - ) - - if success: - return JoinIslandResponse( - success=True, - island_id=request.island_id, - status="joined", - message=f"Successfully joined island {request.island_id}" - ) - else: - return JoinIslandResponse( - success=False, - island_id=request.island_id, - status="failed", - message=f"Failed to join island {request.island_id} (may already be a member)" - ) - - -@router.post("/islands/leave", summary="Leave an island") -async def leave_island(request: LeaveIslandRequest) -> LeaveIslandResponse: - """ - Leave an island. - Calls IslandManager.leave_island to remove the node from the specified island. - """ - island_manager = get_island_manager() - if island_manager is None: - raise HTTPException(status_code=503, detail="Island manager not available") - - success = island_manager.leave_island(request.island_id) - - if success: - return LeaveIslandResponse( - success=True, - island_id=request.island_id, - status="left", - message=f"Successfully left island {request.island_id}" - ) - else: - return LeaveIslandResponse( - success=False, - island_id=request.island_id, - status="failed", - message=f"Failed to leave island {request.island_id} (may not be a member)" - ) - - -@router.get("/islands", summary="List all islands") -@rate_limit(rate=100, per=60) -async def list_islands() -> Dict[str, Any]: - """ - List all islands that the node is a member of. - Calls IslandManager.get_all_islands to retrieve island memberships. - """ - island_manager = get_island_manager() - if island_manager is None: - raise HTTPException(status_code=503, detail="Island manager not available") - - islands = island_manager.get_all_islands() - - return { - "islands": [ - { - "island_id": island.island_id, - "island_name": island.island_name, - "chain_id": island.chain_id, - "status": island.status.value, - "role": getattr(island, 'role', 'unknown'), - "peer_count": island.peer_count, - "is_hub": island.is_hub, - "joined_at": island.joined_at - } - for island in islands - ], - "total": len(islands) - } - - -@router.get("/islands/{island_id}", summary="Get island details") -@rate_limit(rate=100, per=60) -async def get_island(island_id: str) -> Dict[str, Any]: - """ - Get details about a specific island. - Calls IslandManager.get_island_info to retrieve island membership details. - """ - island_manager = get_island_manager() - if island_manager is None: - raise HTTPException(status_code=503, detail="Island manager not available") - - island = island_manager.get_island_info(island_id) - - if island is None: - raise HTTPException(status_code=404, detail=f"Island {island_id} not found") - - return { - "island_id": island.island_id, - "island_name": island.island_name, - "chain_id": island.chain_id, - "status": island.status.value, - "role": getattr(island, 'role', 'unknown'), - "peer_count": island.peer_count, - "is_hub": island.is_hub, - "joined_at": island.joined_at - } - - -@router.post("/islands/bridge", summary="Request a bridge to another island") -async def request_bridge(request: BridgeRequestRequest) -> BridgeRequestResponse: - """ - Request a bridge to another island for cross-island communication. - Calls IslandManager.request_bridge to initiate a bridge request. - """ - island_manager = get_island_manager() - if island_manager is None: - raise HTTPException(status_code=503, detail="Island manager not available") - - request_id = island_manager.request_bridge(request.target_island_id) - - if request_id: - return BridgeRequestResponse( - success=True, - request_id=request_id, - target_island_id=request.target_island_id, - status="pending", - message=f"Bridge request {request_id} submitted for {request.target_island_id}" - ) - else: - return BridgeRequestResponse( - success=False, - request_id="", - target_island_id=request.target_island_id, - status="failed", - message=f"Failed to request bridge to {request.target_island_id} (may already be a member)" - ) - - -@router.get("/accounts/{address}", summary="Get account details") -@rate_limit(rate=200, per=60) -async def get_account( - request: Request, - address: str, - chain_id: str = None -) -> Dict[str, Any]: - """ - Get account details including balance and nonce. - - Args: - address: The account address - chain_id: Optional chain ID (defaults to node's chain) - - Returns: - Account details or 404 if not found - """ - chain_id = get_chain_id(chain_id) - address = address.lower().strip() - - with session_scope() as session: - account = session.get(Account, (chain_id, address)) - if not account: - raise HTTPException(status_code=404, detail=f"Account {address} not found on chain {chain_id}") - - return { - "success": True, - "address": account.address, - "chain_id": account.chain_id, - "balance": account.balance, - "nonce": account.nonce, - "updated_at": account.updated_at.isoformat() if account.updated_at else None - } + """Get account information (alias endpoint)""" + return await get_account_alias(request, address, chain_id) @router.post("/register-account", summary="Create/register a new account on the blockchain") @rate_limit(rate=100, per=60) -async def create_account( +async def create_account_route( request: Request, account_data: dict ) -> Dict[str, Any]: - """ - Create or register a new account on the blockchain. - - This endpoint allows wallets to register their public keys as accounts - on the blockchain, enabling them to send and receive transactions. - - Args: - account_data: Dictionary containing: - - address: The account address/public key (hex string) - - chain_id: Optional chain ID (defaults to node's chain) - - Returns: - Dictionary with success status and account details - """ - chain_id = get_chain_id(account_data.get("chain_id")) - address = account_data.get("address") - - if not address: - raise HTTPException(status_code=400, detail="address is required") - - # Normalize address (ensure lowercase hex) - address = address.lower().strip() - if not address.startswith("0x"): - address = "0x" + address - - # Validate address format (should be hex) - if not all(c in "0123456789abcdef" for c in address[2:]): - raise HTTPException(status_code=400, detail="address must be a valid hex string") - - with session_scope() as session: - # Check if account already exists - existing_account = session.get(Account, (chain_id, address)) - if existing_account: - return { - "success": True, - "address": address, - "chain_id": chain_id, - "balance": existing_account.balance, - "nonce": existing_account.nonce, - "created": False, - "message": "Account already exists" - } - - # Create new account with zero balance - new_account = Account( - chain_id=chain_id, - address=address, - balance=0, - nonce=0 - ) - session.add(new_account) - session.commit() - - _logger.info(f"Created new account: address={address}, chain_id={chain_id}") - - return { - "success": True, - "address": address, - "chain_id": chain_id, - "balance": 0, - "nonce": 0, - "created": True, - "message": "Account created successfully" - } + """Create or register a new account on the blockchain""" + return await create_account(request, account_data) @router.post("/faucet", summary="Request test tokens from faucet") -@rate_limit(rate=10, per=3600) # 10 requests per hour per IP -async def faucet_request( +@rate_limit(rate=10, per=3600) +async def faucet_request_route( request: Request, faucet_data: dict ) -> Dict[str, Any]: - """ - Request test tokens from the blockchain faucet. - - This endpoint allows newly created wallets to receive initial funds - for testing and development purposes. - - Args: - faucet_data: Dictionary containing: - - address: The account address to fund - - amount: Optional amount to request (default: 1000000) - - chain_id: Optional chain ID (defaults to node's chain) - - Returns: - Dictionary with success status and transaction details - """ - chain_id = get_chain_id(faucet_data.get("chain_id")) - address = faucet_data.get("address") - amount = faucet_data.get("amount", 1000000) # Default 1M units - - if not address: - raise HTTPException(status_code=400, detail="address is required") - - # Normalize address - address = address.lower().strip() - if not address.startswith("0x"): - address = "0x" + address - - # Validate address format - if not all(c in "0123456789abcdef" for c in address[2:]): - raise HTTPException(status_code=400, detail="address must be a valid hex string") - - # Cap max faucet amount - if amount > 10000000: # Max 10M per request - amount = 10000000 - - with session_scope() as session: - # Check if account exists - account = session.get(Account, (chain_id, address)) - if not account: - # Auto-create account if it doesn't exist - account = Account(chain_id=chain_id, address=address, balance=0, nonce=0) - session.add(account) - session.flush() - _logger.info(f"Faucet auto-created account: {address}") - - # Generate faucet transaction (special minting transaction) - timestamp = datetime.now(timezone.utc) - tx_hash = hashlib.sha256( - f"faucet:{address}:{amount}:{timestamp.isoformat()}:{uuid.uuid4()}".encode() - ).hexdigest() - - # Apply balance update directly (faucet is special system tx) - account.balance += amount - session.add(account) - - # Create faucet transaction record - faucet_tx = Transaction( - chain_id=chain_id, - tx_hash=tx_hash, - sender="faucet", - recipient=address, - payload={"type": "FAUCET", "amount": amount, "reason": "test_funding"}, - value=amount, - fee=0, - nonce=0, - timestamp=timestamp, - block_height=None, # Not in a block - direct system tx - status="confirmed", - type="FAUCET" - ) - session.add(faucet_tx) - session.commit() - - _logger.info(f"Faucet funded {address} with {amount} units on {chain_id}") - - return { - "success": True, - "tx_hash": tx_hash, - "address": address, - "amount": amount, - "chain_id": chain_id, - "new_balance": account.balance, - "message": f"Successfully funded {address} with {amount} units" - } + """Request test tokens from the blockchain faucet""" + return await faucet_request(request, faucet_data) +@router.get("/balance/{address}", summary="Get detailed balance breakdown") +@rate_limit(rate=100, per=60) +async def get_balance_breakdown_route( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """Get detailed balance breakdown""" + return await get_balance_breakdown(request, address, chain_id) + + +@router.get("/balance/{address}/reconcile", summary="Reconcile balance") +@rate_limit(rate=20, per=60) +async def reconcile_balance_route( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """Reconcile account balance against all recorded operations""" + return await reconcile_balance(request, address, chain_id) + + +# ============================================================================ +# DISPUTE ENDPOINTS +# ============================================================================ + +@router.post("/disputes/file", summary="File a new dispute") +async def file_dispute_route( + request: FileDisputeRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> FileDisputeResponse: + """File a new dispute for a marketplace transaction""" + return await file_dispute(request, http_request, credentials) + + +@router.post("/disputes/evidence", summary="Submit evidence for a dispute") +async def submit_evidence_route( + request: SubmitEvidenceRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> SubmitEvidenceResponse: + """Submit evidence for a dispute""" + return await submit_evidence(request, http_request, credentials) + + +@router.post("/disputes/verify-evidence", summary="Verify evidence (arbitrator only)") +async def verify_evidence_route( + request: VerifyEvidenceRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> VerifyEvidenceResponse: + """Verify evidence submitted in a dispute""" + return await verify_evidence(request, http_request, credentials) + + +@router.post("/disputes/vote", summary="Submit arbitration vote (arbitrator only)") +async def submit_arbitration_vote_route( + request: SubmitArbitrationVoteRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> SubmitArbitrationVoteResponse: + """Submit an arbitration vote for a dispute""" + return await submit_arbitration_vote(request, http_request, credentials) + + +@router.post("/disputes/arbitrators/authorize", summary="Authorize an arbitrator (admin only)") +async def authorize_arbitrator_route( + request: AuthorizeArbitratorRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> AuthorizeArbitratorResponse: + """Authorize a new arbitrator""" + return await authorize_arbitrator(request, http_request, credentials) + + +@router.get("/disputes/active", summary="Get all active disputes") +async def get_active_disputes_route() -> Dict[str, Any]: + """Get all active disputes""" + return await get_active_disputes() + + +@router.get("/disputes/arbitrators", summary="Get all authorized arbitrators") +async def get_authorized_arbitrators_route() -> Dict[str, Any]: + """Get all authorized arbitrators""" + return await get_authorized_arbitrators() + + +@router.get("/disputes/arbitrators/{arbitrator_address}", summary="Get disputes for an arbitrator") +async def get_arbitrator_disputes_route(arbitrator_address: str) -> Dict[str, Any]: + """Get all disputes assigned to an arbitrator""" + return await get_arbitrator_disputes(arbitrator_address) + + +@router.get("/disputes/user/{user_address}", summary="Get disputes for a user") +async def get_user_disputes_route(user_address: str) -> Dict[str, Any]: + """Get all disputes for a specific user""" + return await get_user_disputes(user_address) + + +@router.get("/disputes/{dispute_id}", summary="Get dispute details") +async def get_dispute_route(dispute_id: int) -> GetDisputeResponse: + """Get details of a specific dispute""" + return await get_dispute(dispute_id) + + +@router.get("/disputes/{dispute_id}/evidence", summary="Get evidence for a dispute") +async def get_dispute_evidence_route(dispute_id: int) -> List[GetEvidenceResponse]: + """Get all evidence submitted for a dispute""" + return await get_dispute_evidence(dispute_id) + + +@router.get("/disputes/{dispute_id}/votes", summary="Get arbitration votes for a dispute") +async def get_arbitration_votes_route(dispute_id: int) -> List[GetArbitrationVotesResponse]: + """Get all arbitration votes for a dispute""" + return await get_arbitration_votes(dispute_id) + + +# ============================================================================ +# CONTRACT ENDPOINTS +# ============================================================================ + +@router.post("/contracts/deploy/messaging", summary="Deploy messaging contract") +@rate_limit(rate=50, per=60) +async def deploy_messaging_contract_route( + request: Request, deploy_data: dict +) -> Dict[str, Any]: + """Deploy the agent messaging contract to the blockchain""" + return await deploy_messaging_contract(request, deploy_data) + + +@router.get("/contracts", summary="List deployed contracts") +@rate_limit(rate=200, per=60) +async def list_contracts_route( + request: Request +) -> Dict[str, Any]: + """List all deployed contracts""" + return await list_contracts(request) + + +@router.post("/contracts/deploy", summary="Deploy a smart contract") +@rate_limit(rate=50, per=60) +async def deploy_contract_route( + request: Request, deploy_data: dict +) -> Dict[str, Any]: + """Deploy a new smart contract to the blockchain""" + return await deploy_contract(request, deploy_data) + + +@router.post("/contracts/call", summary="Call a contract method") +@rate_limit(rate=50, per=60) +async def call_contract_route( + request: Request, call_data: dict +) -> Dict[str, Any]: + """Call a method on a deployed contract""" + return await call_contract(request, call_data) + + +@router.post("/contracts/verify", summary="Verify a ZK proof") +@rate_limit(rate=50, per=60) +async def verify_contract_route( + request: Request, verify_data: dict +) -> Dict[str, Any]: + """Verify a ZK proof against a contract""" + return await verify_contract(request, verify_data) + + +@router.get("/contracts/messaging/state", summary="Get messaging contract state") +@rate_limit(rate=200, per=60) +async def get_messaging_contract_state_route( + request: Request +) -> Dict[str, Any]: + """Get the current state of the messaging contract""" + return await get_messaging_contract_state(request) + + +@router.get("/messaging/topics", summary="Get forum topics") +@rate_limit(rate=200, per=60) +async def get_forum_topics_route( + request: Request, limit: int = 50, offset: int = 0, sort_by: str = "last_activity" +) -> Dict[str, Any]: + """Get list of forum topics""" + return await get_forum_topics(request, limit, offset, sort_by) + + +@router.post("/messaging/topics/create", summary="Create forum topic") +@rate_limit(rate=50, per=60) +async def create_forum_topic_route( + request: Request, topic_data: dict +) -> Dict[str, Any]: + """Create a new forum topic""" + return await create_forum_topic(request, topic_data) + + +@router.get("/messaging/topics/{topic_id}/messages", summary="Get topic messages") +@rate_limit(rate=200, per=60) +async def get_topic_messages_route( + request: Request, topic_id: str, limit: int = 50, offset: int = 0, sort_by: str = "timestamp" +) -> Dict[str, Any]: + """Get messages from a forum topic""" + return await get_topic_messages(request, topic_id, limit, offset, sort_by) + + +@router.post("/messaging/messages/post", summary="Post message") +@rate_limit(rate=50, per=60) +async def post_message_route( + request: Request, message_data: dict +) -> Dict[str, Any]: + """Post a message to a forum topic""" + return await post_message(request, message_data) + + +@router.post("/messaging/messages/{message_id}/vote", summary="Vote on message") +@rate_limit(rate=50, per=60) +async def vote_message_route( + request: Request, message_id: str, vote_data: dict +) -> Dict[str, Any]: + """Vote on a message (upvote/downvote)""" + return await vote_message(request, message_id, vote_data) + + +@router.get("/messaging/messages/search", summary="Search messages") +@rate_limit(rate=200, per=60) +async def search_messages_route( + request: Request, query: str, limit: int = 50 +) -> Dict[str, Any]: + """Search messages by content""" + return await search_messages(request, query, limit) + + +@router.get("/messaging/agents/{agent_id}/reputation", summary="Get agent reputation") +@rate_limit(rate=200, per=60) +async def get_agent_reputation_route( + request: Request, agent_id: str +) -> Dict[str, Any]: + """Get agent reputation information""" + return await get_agent_reputation(request, agent_id) + + +@router.post("/messaging/messages/{message_id}/moderate", summary="Moderate message") +@rate_limit(rate=50, per=60) +async def moderate_message_route( + request: Request, message_id: str, moderation_data: dict +) -> Dict[str, Any]: + """Moderate a message (moderator only)""" + return await moderate_message(request, message_id, moderation_data) + + +# ============================================================================ +# SYNC ENDPOINTS +# ============================================================================ + +@router.get("/export-chain", summary="Export full chain state") +@rate_limit(rate=200, per=60) +async def export_chain_route( + request: Request, chain_id: str = None +) -> Dict[str, Any]: + """Export full chain state as JSON for manual synchronization""" + return await export_chain(request, chain_id) + + +@router.post("/import-chain", summary="Import chain state") +@rate_limit(rate=50, per=60) +async def import_chain_route( + request: Request, import_data: dict +) -> Dict[str, Any]: + """Import chain state from JSON for manual synchronization""" + return await import_chain(request, import_data) + + +@router.post("/force-sync", summary="Force reorg to specified peer") +@rate_limit(rate=50, per=60) +async def force_sync_route( + request: Request, peer_data: dict +) -> Dict[str, Any]: + """Force blockchain reorganization to sync with specified peer""" + return await force_sync(request, peer_data) + + +# ============================================================================ +# GOSSIP ENDPOINTS +# ============================================================================ + +@router.post("/eth_getLogs", summary="Query smart contract event logs") +@rate_limit(rate=200, per=60) +async def get_logs_route( + request: Request, + logs_request: GetLogsRequest, + chain_id: Optional[str] = None +) -> GetLogsResponse: + """Query smart contract event logs using eth_getLogs-compatible endpoint""" + return await get_logs(request, logs_request, chain_id) + + +# ============================================================================ +# ISLAND ENDPOINTS +# ============================================================================ + +@router.post("/islands/join", summary="Join an island") +async def join_island_route(request: JoinIslandRequest) -> JoinIslandResponse: + """Join an island for edge compute operations""" + return await join_island(request) + + +@router.post("/islands/leave", summary="Leave an island") +async def leave_island_route(request: LeaveIslandRequest) -> LeaveIslandResponse: + """Leave an island""" + return await leave_island(request) + + +@router.get("/islands", summary="List all islands") +@rate_limit(rate=100, per=60) +async def list_islands_route() -> Dict[str, Any]: + """List all islands that the node is a member of""" + return await list_islands() + + +@router.get("/islands/{island_id}", summary="Get island details") +@rate_limit(rate=100, per=60) +async def get_island_route(island_id: str) -> Dict[str, Any]: + """Get details about a specific island""" + return await get_island(island_id) + + +@router.post("/islands/bridge", summary="Request a bridge to another island") +async def request_bridge_route(request: BridgeRequestRequest) -> BridgeRequestResponse: + """Request a bridge to another island for cross-island communication""" + return await request_bridge(request) + + +# ============================================================================ +# BRIDGE ENDPOINTS +# ============================================================================ + @router.post("/bridge/lock", summary="Lock funds for cross-chain transfer") @rate_limit(rate=20, per=60) -async def bridge_lock( +async def bridge_lock_route( request: Request, lock_data: dict ) -> Dict[str, Any]: - """ - Initiate a cross-chain bridge transfer by locking funds. - - This is step 1 of the atomic bridge: - 1. Lock funds on source chain (this endpoint) - 2. Generate proof - 3. Confirm on target chain - """ - try: - from ..cross_chain.bridge import get_cross_chain_bridge - bridge = get_cross_chain_bridge() - - if not bridge: - raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") - - source_chain = lock_data.get("source_chain", get_chain_id(None)) - target_chain = lock_data.get("target_chain") - sender = lock_data.get("sender") - recipient = lock_data.get("recipient") - amount = lock_data.get("amount", 0) - asset = lock_data.get("asset", "native") - - if not all([target_chain, sender, recipient]): - raise HTTPException(status_code=400, detail="Missing required fields: target_chain, sender, recipient") - - if amount <= 0: - raise HTTPException(status_code=400, detail="Amount must be positive") - - # Execute lock - transfer = bridge.initiate_transfer( - source_chain=source_chain, - target_chain=target_chain, - sender=sender.lower(), - recipient=recipient.lower(), - amount=amount, - asset=asset - ) - - return { - "success": True, - "transfer_id": transfer.transfer_id, - "status": transfer.status.value, - "source_chain": source_chain, - "target_chain": target_chain, - "sender": sender, - "recipient": recipient, - "amount": amount, - "fee": (amount * 10) // 10000, # 0.1% fee - "lock_time": transfer.lock_time.isoformat() if transfer.lock_time else None, - "message": "Funds locked successfully. Use /bridge/confirm to complete." - } - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - _logger.error(f"Bridge lock failed: {e}") - raise HTTPException(status_code=500, detail=f"Bridge lock failed: {str(e)}") + """Initiate a cross-chain bridge transfer by locking funds""" + return await bridge_lock(request, lock_data) @router.post("/bridge/confirm", summary="Confirm and release cross-chain transfer") @rate_limit(rate=20, per=60) -async def bridge_confirm( +async def bridge_confirm_route( request: Request, confirm_data: dict ) -> Dict[str, Any]: - """ - Confirm a cross-chain bridge transfer and release funds. - - This is step 2 of the atomic bridge: - 1. Validate proof of lock - 2. Release funds on target chain - 3. Mark transfer as complete - """ - try: - from ..cross_chain.bridge import get_cross_chain_bridge - bridge = get_cross_chain_bridge() - - if not bridge: - raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") - - transfer_id = confirm_data.get("transfer_id") - proof = confirm_data.get("proof") - - if not transfer_id or not proof: - raise HTTPException(status_code=400, detail="Missing required fields: transfer_id, proof") - - # Execute confirmation - transfer = bridge.confirm_transfer(transfer_id, proof) - - return { - "success": True, - "transfer_id": transfer.transfer_id, - "status": transfer.status.value, - "source_chain": transfer.source_chain, - "target_chain": transfer.target_chain, - "sender": transfer.sender, - "recipient": transfer.recipient, - "amount": transfer.amount, - "target_tx_hash": transfer.target_tx_hash, - "confirm_time": transfer.confirm_time.isoformat() if transfer.confirm_time else None, - "message": "Cross-chain transfer completed successfully" - } - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - _logger.error(f"Bridge confirm failed: {e}") - raise HTTPException(status_code=500, detail=f"Bridge confirm failed: {str(e)}") + """Confirm a cross-chain bridge transfer and release funds""" + return await bridge_confirm(request, confirm_data) @router.get("/bridge/transfer/{transfer_id}", summary="Get transfer status") @rate_limit(rate=100, per=60) -async def get_bridge_transfer( +async def get_bridge_transfer_route( request: Request, transfer_id: str ) -> Dict[str, Any]: """Get the status of a cross-chain transfer""" - try: - from ..cross_chain.bridge import get_cross_chain_bridge - bridge = get_cross_chain_bridge() - - if not bridge: - raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") - - transfer = bridge.get_transfer(transfer_id) - if not transfer: - raise HTTPException(status_code=404, detail=f"Transfer {transfer_id} not found") - - return { - "success": True, - "transfer_id": transfer.transfer_id, - "status": transfer.status.value, - "source_chain": transfer.source_chain, - "target_chain": transfer.target_chain, - "sender": transfer.sender, - "recipient": transfer.recipient, - "amount": transfer.amount, - "asset": transfer.asset, - "source_tx_hash": transfer.source_tx_hash, - "target_tx_hash": transfer.target_tx_hash, - "lock_time": transfer.lock_time.isoformat() if transfer.lock_time else None, - "confirm_time": transfer.confirm_time.isoformat() if transfer.confirm_time else None - } - - except HTTPException: - raise - except Exception as e: - _logger.error(f"Get bridge transfer failed: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get transfer: {str(e)}") + return await get_bridge_transfer(request, transfer_id) @router.get("/bridge/pending", summary="List pending bridge transfers") @rate_limit(rate=50, per=60) -async def list_pending_transfers( +async def list_pending_transfers_route( request: Request, chain_id: str = None ) -> List[Dict[str, Any]]: """List all pending cross-chain transfers""" - try: - from ..cross_chain.bridge import get_cross_chain_bridge - bridge = get_cross_chain_bridge() - - if not bridge: - raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") - - chain_id = get_chain_id(chain_id) - transfers = bridge.list_pending_transfers(chain_id) - - return [ - { - "transfer_id": t.transfer_id, - "source_chain": t.source_chain, - "target_chain": t.target_chain, - "sender": t.sender, - "recipient": t.recipient, - "amount": t.amount, - "status": t.status.value, - "lock_time": t.lock_time.isoformat() if t.lock_time else None - } - for t in transfers - ] - - except Exception as e: - _logger.error(f"List pending transfers failed: {e}") - raise HTTPException(status_code=500, detail=f"Failed to list transfers: {str(e)}") + return await list_pending_transfers(request, chain_id) +# ============================================================================ +# STAKING ENDPOINTS +# ============================================================================ + @router.post("/staking/stake", summary="Stake tokens") @rate_limit(rate=20, per=60) -async def stake_tokens( +async def stake_tokens_route( request: Request, stake_data: dict ) -> Dict[str, Any]: - """ - Stake tokens for consensus participation. - - Locks tokens for a specified period. Staked tokens earn rewards - and provide voting power in consensus. - """ - chain_id = get_chain_id(stake_data.get("chain_id")) - address = stake_data.get("address") - amount = stake_data.get("amount", 0) - lock_days = stake_data.get("lock_days", 30) - - if not address: - raise HTTPException(status_code=400, detail="address is required") - - if amount <= 0: - raise HTTPException(status_code=400, detail="amount must be positive") - - # Normalize address - address = address.lower().strip() - if not address.startswith("0x"): - address = "0x" + address - - with session_scope() as session: - # Get account - account = session.get(Account, (chain_id, address)) - if not account: - raise HTTPException(status_code=404, detail=f"Account {address} not found") - - if account.balance < amount: - raise HTTPException( - status_code=400, - detail=f"Insufficient balance: {account.balance} < {amount}" - ) - - # Lock tokens (deduct from balance) - account.balance -= amount - session.add(account) - - # Calculate lock period - locked_until = datetime.now(timezone.utc) - locked_until = locked_until.replace(day=locked_until.day + lock_days) - - # Create stake record - stake = Stake( - chain_id=chain_id, - address=address, - amount=amount, - locked_until=locked_until, - status="active" - ) - session.add(stake) - session.commit() - - _logger.info(f"Tokens staked: {address} staked {amount} on {chain_id}") - - return { - "success": True, - "stake_id": stake.id, - "address": address, - "amount": amount, - "chain_id": chain_id, - "locked_until": locked_until.isoformat(), - "status": "active", - "remaining_balance": account.balance - } + """Stake tokens for consensus participation""" + return await stake_tokens(request, stake_data) @router.post("/staking/unstake", summary="Unstake tokens") @rate_limit(rate=10, per=60) -async def unstake_tokens( +async def unstake_tokens_route( request: Request, unstake_data: dict ) -> Dict[str, Any]: - """ - Unstake tokens after lock period expires. - - Returns staked tokens to account balance. - """ - chain_id = get_chain_id(unstake_data.get("chain_id")) - address = unstake_data.get("address") - stake_id = unstake_data.get("stake_id") - - if not address or not stake_id: - raise HTTPException(status_code=400, detail="address and stake_id are required") - - # Normalize address - address = address.lower().strip() - if not address.startswith("0x"): - address = "0x" + address - - with session_scope() as session: - # Get stake record - stake = session.get(Stake, stake_id) - if not stake: - raise HTTPException(status_code=404, detail=f"Stake {stake_id} not found") - - if stake.address != address: - raise HTTPException(status_code=403, detail="Not authorized to unstake") - - if stake.status != "active": - raise HTTPException(status_code=400, detail=f"Stake is not active: {stake.status}") - - # Check if lock period expired - now = datetime.now(timezone.utc) - if stake.locked_until and now < stake.locked_until: - raise HTTPException( - status_code=400, - detail=f"Lock period not expired. Locked until: {stake.locked_until.isoformat()}" - ) - - # Return tokens to account - account = session.get(Account, (chain_id, address)) - if not account: - # Account was deleted, recreate - account = Account(chain_id=chain_id, address=address, balance=0, nonce=0) - session.add(account) - - account.balance += stake.amount - session.add(account) - - # Update stake status - stake.status = "withdrawn" - session.add(stake) - session.commit() - - _logger.info(f"Tokens unstaked: {address} recovered {stake.amount} from stake {stake_id}") - - return { - "success": True, - "stake_id": stake_id, - "address": address, - "amount": stake.amount, - "chain_id": chain_id, - "new_balance": account.balance, - "status": "withdrawn" - } + """Unstake tokens after lock period expires""" + return await unstake_tokens(request, unstake_data) @router.get("/staking/{address}", summary="Get staking info") @rate_limit(rate=100, per=60) -async def get_staking_info( +async def get_staking_info_route( request: Request, address: str, chain_id: str = None ) -> Dict[str, Any]: """Get staking information for an address""" - chain_id = get_chain_id(chain_id) - address = address.lower().strip() - - with session_scope() as session: - from sqlalchemy import select, func - - # Get all stakes for address - statement = select(Stake).where( - Stake.chain_id == chain_id, - Stake.address == address - ) - stakes = session.exec(statement).all() - - total_staked = sum(s.amount for s in stakes if s.status == "active") - active_stakes = [ - { - "stake_id": s.id, - "amount": s.amount, - "locked_until": s.locked_until.isoformat() if s.locked_until else None, - "status": s.status, - "created_at": s.created_at.isoformat() if s.created_at else None - } - for s in stakes if s.status == "active" - ] - - return { - "success": True, - "address": address, - "chain_id": chain_id, - "total_staked": total_staked, - "active_stake_count": len(active_stakes), - "active_stakes": active_stakes - } - - -@router.get("/balance/{address}", summary="Get detailed balance breakdown") -@rate_limit(rate=100, per=60) -async def get_balance_breakdown( - request: Request, - address: str, - chain_id: str = None -) -> Dict[str, Any]: - """ - Get detailed balance breakdown including: - - Available balance - - Staked amount - - Bridge-locked amount - - Total balance - """ - try: - from ..services.balance_tracker import get_balance_tracker - tracker = get_balance_tracker() - - if not tracker: - raise HTTPException(status_code=503, detail="Balance tracker not initialized") - - chain_id = get_chain_id(chain_id) - address = address.lower().strip() - - breakdown = tracker.get_balance_breakdown(address, chain_id) - return breakdown - - except HTTPException: - raise - except Exception as e: - _logger.error(f"Failed to get balance breakdown: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get balance: {str(e)}") - - -@router.get("/balance/{address}/reconcile", summary="Reconcile balance") -@rate_limit(rate=20, per=60) -async def reconcile_balance( - request: Request, - address: str, - chain_id: str = None -) -> Dict[str, Any]: - """ - Reconcile account balance against all recorded operations. - - Verifies that current balance matches expected balance - based on all transactions, stakes, and bridge operations. - """ - try: - from ..services.balance_tracker import get_balance_tracker - tracker = get_balance_tracker() - - if not tracker: - raise HTTPException(status_code=503, detail="Balance tracker not initialized") - - chain_id = get_chain_id(chain_id) - address = address.lower().strip() - - result = tracker.reconcile_balance(address, chain_id) - return result - - except HTTPException: - raise - except Exception as e: - _logger.error(f"Balance reconciliation failed: {e}") - raise HTTPException(status_code=500, detail=f"Reconciliation failed: {str(e)}") + return await get_staking_info(request, address, chain_id) diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/router_old.py b/apps/blockchain-node/src/aitbc_chain/rpc/router_old.py new file mode 100644 index 00000000..6bc08b3d --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/router_old.py @@ -0,0 +1,2473 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, Dict, Optional, List +from datetime import datetime, timezone, timedelta + +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel, Field, model_validator +from sqlmodel import select, delete + +from ..database import session_scope, get_engine +from ..gossip import gossip_broker +from ..mempool import get_mempool +from ..metrics import metrics_registry +from ..models import Account, Block, Receipt, Transaction +from ..logger import get_logger +from ..sync import ChainSync +from .auth import get_authenticated_address +from .utils import ( + set_poa_proposer, + get_poa_proposer, + get_chain_id, + validate_chain_id, + get_supported_chains, + get_chain_db, + normalize_transaction_data, +) + +from aitbc.rate_limiting import rate_limit + +# Import domain modules +from .blocks import ( + get_genesis_allocations, + get_head, + get_block, + get_blocks_range, + import_block, +) +from .transactions import ( + submit_transaction, + get_mempool, + submit_marketplace_transaction, + query_transactions, + TransactionRequest, +) +from .accounts import ( + get_account, + get_account_alias, + get_account_details, + create_account, + faucet_request, + get_balance_breakdown, + reconcile_balance, +) +from .disputes import ( + file_dispute, + submit_evidence, + verify_evidence, + submit_arbitration_vote, + authorize_arbitrator, + get_active_disputes, + get_authorized_arbitrators, + get_arbitrator_disputes, + get_user_disputes, + get_dispute, + get_dispute_evidence, + get_arbitration_votes, +) +from .contracts import ( + deploy_messaging_contract, + list_contracts, + deploy_contract, + call_contract, + verify_contract, + get_messaging_contract_state, + get_forum_topics, + create_forum_topic, + get_topic_messages, + post_message, + vote_message, + search_messages, + get_agent_reputation, + moderate_message, +) +from .sync import ( + export_chain, + import_chain, + force_sync, +) +from .gossip import ( + get_logs, + GetLogsRequest, + GetLogsResponse, +) +from .islands import ( + join_island, + leave_island, + list_islands, + get_island, + request_bridge, + JoinIslandRequest, + JoinIslandResponse, + LeaveIslandRequest, + LeaveIslandResponse, + BridgeRequestRequest, + BridgeRequestResponse, +) +from .bridge import ( + bridge_lock, + bridge_confirm, + get_bridge_transfer, + list_pending_transfers, +) +from .staking import ( + stake_tokens, + unstake_tokens, + get_staking_info, +) + +_logger = get_logger(__name__) + +# Security scheme for authentication +security = HTTPBearer(auto_error=False) + +router = APIRouter() + +# Global rate limiter for importBlock +_last_import_time = 0 +_import_lock = asyncio.Lock() + + + + + +@router.get("/genesis_allocations", summary="Get genesis allocations from blockchain") +@rate_limit(rate=200, per=60) +async def get_genesis_allocations( + request: Request, chain_id: str = None +) -> Dict[str, Any]: + """Get genesis allocations from genesis block metadata for RPC bootstrap""" + from .blocks import get_genesis_allocations as _get_genesis_allocations + return await _get_genesis_allocations(request, chain_id) + + +@router.get("/head", summary="Get current chain head") +@rate_limit(rate=200, per=60) +async def get_head( + request: Request, chain_id: str = None +) -> Dict[str, Any]: + """Get current chain head""" + from .blocks import get_head as _get_head + return await _get_head(request, chain_id) + + +@router.get("/blocks/{height}", summary="Get block by height") +@rate_limit(rate=200, per=60) +async def get_block( + request: Request, height: int, chain_id: str = None +) -> Dict[str, Any]: + """Get block by height""" + from .blocks import get_block as _get_block + return await _get_block(request, height, chain_id) + + +@router.post("/transaction", summary="Submit transaction") +@rate_limit(rate=50, per=60) +async def submit_transaction( + request: Request, tx_data: TransactionRequest +) -> Dict[str, Any]: + """Submit a new transaction to the mempool""" + from ..mempool import get_mempool + + try: + mempool = get_mempool() + chain_id = get_chain_id(None) + + # Convert TransactionRequest to dict for normalization + # Model validator already normalized payload, so use 'to' directly from payload + tx_data_dict = { + "from": tx_data.sender, + "to": tx_data.payload.get("to"), # Model validator sets this from recipient/to + "amount": tx_data.payload.get("amount", tx_data.payload.get("value", 0)), + "fee": tx_data.fee, + "nonce": tx_data.nonce, + "payload": tx_data.payload, + "type": tx_data.type, + "signature": tx_data.sig + } + + tx_data_dict = normalize_transaction_data(tx_data_dict, chain_id) + _validate_transaction_admission(tx_data_dict, mempool) + + tx_hash = mempool.add(tx_data_dict, chain_id=chain_id) + + return { + "success": True, + "transaction_hash": tx_hash, + "message": "Transaction submitted to mempool" + } + except Exception as e: + _logger.error("Failed to submit transaction", extra={"error": str(e)}) + raise HTTPException(status_code=400, detail=f"Failed to submit transaction: {str(e)}") + + +@router.get("/mempool", summary="Get pending transactions") +@rate_limit(rate=200, per=60) +async def get_mempool( + request: Request, chain_id: str = None, limit: int = 100 +) -> Dict[str, Any]: + """Get pending transactions from mempool""" + from ..mempool import get_mempool + + try: + mempool = get_mempool() + pending_txs = mempool.get_pending_transactions(chain_id=chain_id, limit=limit) + + return { + "success": True, + "transactions": pending_txs, + "count": len(pending_txs) + } + except Exception as e: + _logger.error(f"Failed to get mempool", extra={"error": str(e)}) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get mempool: {str(e)}") + + +@router.get("/account/{address}", summary="Get account information") +@rate_limit(rate=200, per=60) +async def get_account( + request: Request, address: str, chain_id: str = None +) -> Dict[str, Any]: + """Get account information""" + chain_id = get_chain_id(chain_id) + + with session_scope() as session: + account = session.exec(select(Account).where(Account.address == address).where(Account.chain_id == chain_id)).first() + if not account: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + return { + "address": account.address, + "balance": account.balance, + "nonce": account.nonce, + "chain_id": account.chain_id + } + + +@router.get("/accounts/{address}", summary="Get account information (alias)") +@rate_limit(rate=200, per=60) +async def get_account_alias( + request: Request, address: str, chain_id: str = None +) -> Dict[str, Any]: + """Get account information (alias endpoint)""" + return await get_account(address, chain_id) + + +@router.post("/transactions/marketplace", summary="Submit marketplace transaction") +@rate_limit(rate=50, per=60) +async def submit_marketplace_transaction( + request: Request, tx_data: Dict[str, Any] +) -> Dict[str, Any]: + """Submit a marketplace purchase transaction to the blockchain""" + from ..config import settings as cfg + chain_id = get_chain_id(tx_data.get("chain_id")) + + metrics_registry.increment("rpc_marketplace_transaction_total") + start = time.perf_counter() + + try: + with session_scope() as session: + # Validate sender account + sender_addr = tx_data.get("from") + sender_account = session.get(Account, (chain_id, sender_addr)) + if not sender_account: + raise ValueError(f"Sender account not found: {sender_addr}") + + # Validate balance + amount = tx_data.get("value", 0) + fee = tx_data.get("fee", 0) + total_cost = amount + fee + + if sender_account.balance < total_cost: + raise ValueError(f"Insufficient balance: {sender_account.balance} < {total_cost}") + + # Validate nonce + tx_nonce = tx_data.get("nonce", 0) + if tx_nonce != sender_account.nonce: + raise ValueError(f"Invalid nonce: expected {sender_account.nonce}, got {tx_nonce}") + + # Get or create recipient account + recipient_addr = tx_data.get("to") + recipient_account = session.get(Account, (chain_id, recipient_addr)) + if not recipient_account: + recipient_account = Account( + chain_id=chain_id, + address=recipient_addr, + balance=0, + nonce=0 + ) + session.add(recipient_account) + + # Create transaction record + tx_hash = compute_tx_hash(tx_data) + transaction = Transaction( + chain_id=chain_id, + tx_hash=tx_hash, + sender=sender_addr, + recipient=recipient_addr, + payload=tx_data.get("payload", {}), + created_at=datetime.now(timezone.utc), + nonce=tx_nonce, + value=amount, + fee=fee, + status="pending", + timestamp=datetime.now(timezone.utc).isoformat() + ) + session.add(transaction) + + # Update account balances (pending state) + sender_account.balance -= total_cost + sender_account.nonce += 1 + recipient_account.balance += amount + + metrics_registry.increment("rpc_marketplace_transaction_success") + duration = time.perf_counter() - start + metrics_registry.observe("rpc_marketplace_transaction_duration_seconds", duration) + + _logger.info(f"Marketplace transaction submitted: {tx_hash[:16]}... from {sender_addr[:16]}... to {recipient_addr[:16]}... amount={amount}") + + return { + "success": True, + "tx_hash": tx_hash, + "status": "pending", + "chain_id": chain_id, + "amount": amount, + "fee": fee, + "from": sender_addr, + "to": recipient_addr + } + + except ValueError as e: + metrics_registry.increment("rpc_marketplace_transaction_validation_errors_total") + _logger.error(f"Marketplace transaction validation failed: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + metrics_registry.increment("rpc_marketplace_transaction_errors_total") + _logger.error(f"Failed to submit marketplace transaction", extra={"error": str(e)}) + raise HTTPException(status_code=500, detail=f"Failed to submit marketplace transaction: {str(e)}") + + +@router.get("/transactions", summary="Query transactions") +@rate_limit(rate=200, per=60) +async def query_transactions( + request: Request, + transaction_type: Optional[str] = None, + island_id: Optional[str] = None, + pair: Optional[str] = None, + status: Optional[str] = None, + order_id: Optional[str] = None, + limit: Optional[int] = 100, + chain_id: str = None +) -> List[Dict[str, Any]]: + """Query transactions with optional filters""" + chain_id = get_chain_id(chain_id) + + with session_scope() as session: + query = select(Transaction).where(Transaction.chain_id == chain_id) + + # Apply filters based on payload fields + transactions = session.exec(query).all() + + results = [] + for tx in transactions: + # Filter by transaction type in payload + if transaction_type and tx.payload.get('type') != transaction_type: + continue + + # Filter by island_id in payload + if island_id and tx.payload.get('island_id') != island_id: + continue + + # Filter by pair in payload + if pair and tx.payload.get('pair') != pair: + continue + + # Filter by status in payload + if status and tx.payload.get('status') != status: + continue + + # Filter by order_id in payload + if order_id and tx.payload.get('order_id') != order_id and tx.payload.get('offer_id') != order_id and tx.payload.get('bid_id') != order_id: + continue + + results.append({ + "transaction_id": tx.id, + "tx_hash": tx.tx_hash, + "sender": tx.sender, + "recipient": tx.recipient, + "payload": tx.payload, + "status": tx.status, + "created_at": tx.created_at.isoformat(), + "timestamp": tx.timestamp, + "nonce": tx.nonce, + "value": tx.value, + "fee": tx.fee + }) + + # Apply limit + if limit: + results = results[:limit] + + return results + + +@router.get("/blocks-range", summary="Get blocks in height range") +@rate_limit(rate=200, per=60) +async def get_blocks_range( + request: Request, start: int = 0, end: int = 10, include_tx: bool = True, chain_id: str = None +) -> Dict[str, Any]: + """Get blocks in a height range + + Args: + start: Starting block height (inclusive) + end: Ending block height (inclusive) + include_tx: Whether to include transaction data (default: True) + """ + with session_scope() as session: + from ..models import Transaction + chain_id = get_chain_id(chain_id) + + blocks = session.exec( + select(Block).where( + Block.chain_id == chain_id, + Block.height >= start, + Block.height <= end, + ).order_by(Block.height.asc()) + ).all() + + result_blocks = [] + for b in blocks: + block_data = { + "height": b.height, + "hash": b.hash, + "parent_hash": b.parent_hash, + "proposer": b.proposer, + "timestamp": b.timestamp.isoformat(), + "tx_count": b.tx_count, + "state_root": b.state_root, + } + + if include_tx: + # Fetch transactions for this block + txs = session.exec( + select(Transaction) + .where(Transaction.chain_id == chain_id) + .where(Transaction.block_height == b.height) + ).all() + block_data["transactions"] = [tx.model_dump() for tx in txs] + + result_blocks.append(block_data) + + return { + "success": True, + "blocks": result_blocks, + "count": len(blocks), + } + +@router.post("/contracts/deploy/messaging", summary="Deploy messaging contract") +@rate_limit(rate=50, per=60) +async def deploy_messaging_contract( + request: Request, deploy_data: dict +) -> Dict[str, Any]: + """Deploy the agent messaging contract to the blockchain""" + contract_address = "0xagent_messaging_001" + return {"success": True, "contract_address": contract_address, "status": "deployed"} + +@router.get("/contracts", summary="List deployed contracts") +@rate_limit(rate=200, per=60) +async def list_contracts( + request: Request +) -> Dict[str, Any]: + """List all deployed contracts""" + return contract_service.list_contracts() + +@router.post("/contracts/deploy", summary="Deploy a smart contract") +@rate_limit(rate=50, per=60) +async def deploy_contract( + request: Request, deploy_data: dict +) -> Dict[str, Any]: + """Deploy a new smart contract to the blockchain""" + contract_name = deploy_data.get("name") + contract_type = deploy_data.get("type", "zk-verifier") + + if not contract_name: + return {"success": False, "error": "Contract name is required"} + + # Generate a mock contract address for now + contract_address = f"0x{contract_name.lower()}_{int(time.time())}" + + return { + "success": True, + "contract_address": contract_address, + "name": contract_name, + "type": contract_type, + "status": "deployed", + "deployed_at": datetime.now(UTC).isoformat() + } + +@router.post("/contracts/call", summary="Call a contract method") +@rate_limit(rate=50, per=60) +async def call_contract( + request: Request, call_data: dict +) -> Dict[str, Any]: + """Call a method on a deployed contract""" + contract_address = call_data.get("address") + method = call_data.get("method") + params = call_data.get("params") + + if not contract_address: + return {"success": False, "error": "Contract address is required"} + if not method: + return {"success": False, "error": "Method name is required"} + + # Mock call result for now + return { + "success": True, + "result": f"Called {method} on {contract_address}", + "address": contract_address, + "method": method + } + +@router.post("/contracts/verify", summary="Verify a ZK proof") +@rate_limit(rate=50, per=60) +async def verify_contract( + request: Request, verify_data: dict +) -> Dict[str, Any]: + """Verify a ZK proof against a contract""" + contract_address = verify_data.get("address") + proof = verify_data.get("proof") + + if not contract_address: + return {"success": False, "error": "Contract address is required"} + + # Mock verification result for now + return { + "success": True, + "result": { + "valid": True, + "receipt_hash": "0xmock_receipt_hash", + "address": contract_address + } + } + +@router.get("/contracts/messaging/state", summary="Get messaging contract state") +@rate_limit(rate=200, per=60) +async def get_messaging_contract_state( + request: Request +) -> Dict[str, Any]: + """Get the current state of the messaging contract""" + state = { + "total_topics": len(messaging_contract.topics), + "total_messages": len(messaging_contract.messages), + "total_agents": len(messaging_contract.agent_reputations) + } + return {"success": True, "contract_state": state} + +@router.get("/messaging/topics", summary="Get forum topics") +@rate_limit(rate=200, per=60) +async def get_forum_topics( + request: Request, limit: int = 50, offset: int = 0, sort_by: str = "last_activity" +) -> Dict[str, Any]: + """Get list of forum topics""" + return messaging_contract.get_topics(limit, offset, sort_by) + +@router.post("/messaging/topics/create", summary="Create forum topic") +@rate_limit(rate=50, per=60) +async def create_forum_topic( + request: Request, topic_data: dict +) -> Dict[str, Any]: + """Create a new forum topic""" + return messaging_contract.create_topic( + topic_data.get("agent_id"), + topic_data.get("agent_address"), + topic_data.get("title"), + topic_data.get("description"), + topic_data.get("tags", []) + ) + +@router.get("/messaging/topics/{topic_id}/messages", summary="Get topic messages") +@rate_limit(rate=200, per=60) +async def get_topic_messages( + request: Request, topic_id: str, limit: int = 50, offset: int = 0, sort_by: str = "timestamp" +) -> Dict[str, Any]: + """Get messages from a forum topic""" + return messaging_contract.get_messages(topic_id, limit, offset, sort_by) + +@router.post("/messaging/messages/post", summary="Post message") +@rate_limit(rate=50, per=60) +async def post_message( + request: Request, message_data: dict +) -> Dict[str, Any]: + """Post a message to a forum topic""" + return messaging_contract.post_message( + message_data.get("agent_id"), + message_data.get("agent_address"), + message_data.get("topic_id"), + message_data.get("content"), + message_data.get("message_type", "post"), + message_data.get("parent_message_id") + ) + +@router.post("/messaging/messages/{message_id}/vote", summary="Vote on message") +@rate_limit(rate=50, per=60) +async def vote_message( + request: Request, message_id: str, vote_data: dict +) -> Dict[str, Any]: + """Vote on a message (upvote/downvote)""" + return messaging_contract.vote_message( + vote_data.get("agent_id"), + vote_data.get("agent_address"), + message_id, + vote_data.get("vote_type") + ) + +@router.get("/messaging/messages/search", summary="Search messages") +@rate_limit(rate=200, per=60) +async def search_messages( + request: Request, query: str, limit: int = 50 +) -> Dict[str, Any]: + """Search messages by content""" + return messaging_contract.search_messages(query, limit) + +@router.get("/messaging/agents/{agent_id}/reputation", summary="Get agent reputation") +@rate_limit(rate=200, per=60) +async def get_agent_reputation( + request: Request, agent_id: str +) -> Dict[str, Any]: + """Get agent reputation information""" + return messaging_contract.get_agent_reputation(agent_id) + +@router.post("/messaging/messages/{message_id}/moderate", summary="Moderate message") +@rate_limit(rate=50, per=60) +async def moderate_message( + request: Request, message_id: str, moderation_data: dict +) -> Dict[str, Any]: + """Moderate a message (moderator only)""" + return messaging_contract.moderate_message( + moderation_data.get("moderator_agent_id"), + moderation_data.get("moderator_address"), + message_id, + moderation_data.get("action"), + moderation_data.get("reason", "") + ) + +@router.post("/importBlock", summary="Import a block") +@rate_limit(rate=50, per=60) +async def import_block( + request: Request, block_data: dict +) -> Dict[str, Any]: + """Import a block into the blockchain""" + global _last_import_time + + async with _import_lock: + try: + # Rate limiting: max 1 import per second + current_time = time.time() + time_since_last = current_time - _last_import_time + if time_since_last < 1.0: + await asyncio.sleep(1.0 - time_since_last) + + _last_import_time = time.time() + + chain_id = block_data.get("chain_id") or block_data.get("chainId") or get_chain_id(None) + block_hash = block_data["hash"] + + # Validate block hash format: must be 0x followed by exactly 64 hex characters + if not isinstance(block_hash, str) or not re.fullmatch(r"0x[0-9a-fA-F]{64}", block_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid block hash format") + + try: + block_height = int(block_data["height"]) + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid block height") from exc + + timestamp = block_data.get("timestamp") + if isinstance(timestamp, str): + try: + timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + except ValueError: + timestamp = datetime.now(timezone.utc) + elif timestamp is None: + timestamp = datetime.now(timezone.utc) + + with session_scope(chain_id) as session: + existing_height_block = session.exec( + select(Block) + .where(Block.chain_id == chain_id) + .where(Block.height == block_height) + ).first() + if existing_height_block is not None: + if existing_height_block.hash == block_hash: + return { + "success": True, + "block_height": existing_height_block.height, + "block_hash": existing_height_block.hash, + "chain_id": chain_id + } + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Block height {block_height} already exists with different hash", + ) + + # Validate parent block exists (skip for genesis block height 1) + parent_hash = block_data["parent_hash"] + if block_height > 1: + parent_block = session.exec( + select(Block).where(Block.hash == parent_hash) + ).first() + if parent_block is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Parent block not found", + ) + + # Check for hash conflicts across chains + existing_block = session.execute( + select(Block).where(Block.hash == block_hash) + ).first() + + if existing_block: + # Delete existing block with conflicting hash + _logger.warning(f"Deleting existing block with conflicting hash {block_hash} from chain {existing_block[0].chain_id}") + session.execute(delete(Block).where(Block.hash == block_hash)) + session.commit() + + # Create block + block = Block( + chain_id=chain_id, + height=block_height, + hash=block_hash, + parent_hash=block_data["parent_hash"], + proposer=block_data["proposer"], + timestamp=timestamp, + state_root=block_data.get("state_root"), + tx_count=block_data.get("tx_count", 0) + ) + session.add(block) + session.commit() + + return { + "success": True, + "block_height": block.height, + "block_hash": block.hash, + "chain_id": chain_id + } + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error importing block: {e}") + raise HTTPException(status_code=500, detail=f"Failed to import block: {str(e)}") + +def _serialize_optional_timestamp(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + return value + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value) + +def _parse_datetime_value(value: Any, field_name: str) -> Optional[datetime]: + if value in (None, ""): + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {field_name}: {value}") from exc + raise HTTPException(status_code=400, detail=f"Invalid {field_name} type: {type(value).__name__}") + +def _select_export_blocks(session, chain_id: str) -> List[Block]: + blocks_result = session.execute( + select(Block) + .where(Block.chain_id == chain_id) + .order_by(Block.height.asc(), Block.id.desc()) + ) + blocks: List[Block] = [] + seen_heights = set() + duplicate_count = 0 + for block in blocks_result.scalars().all(): + if block.height in seen_heights: + duplicate_count += 1 + continue + seen_heights.add(block.height) + blocks.append(block) + if duplicate_count: + _logger.warning(f"Filtered {duplicate_count} duplicate exported blocks for chain {chain_id}") + return blocks + +def _dedupe_import_blocks(blocks: List[Dict[str, Any]], chain_id: str) -> List[Dict[str, Any]]: + latest_by_height: Dict[int, Dict[str, Any]] = {} + duplicate_count = 0 + for block_data in blocks: + if "height" not in block_data: + raise HTTPException(status_code=400, detail="Block height is required") + try: + height = int(block_data["height"]) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=f"Invalid block height: {block_data.get('height')}") from exc + block_chain_id = block_data.get("chain_id") + if block_chain_id and block_chain_id != chain_id: + raise HTTPException( + status_code=400, + detail=f"Mismatched block chain_id '{block_chain_id}' for import chain '{chain_id}'", + ) + normalized_block = dict(block_data) + normalized_block["height"] = height + normalized_block["chain_id"] = chain_id + if height in latest_by_height: + duplicate_count += 1 + latest_by_height[height] = normalized_block + if duplicate_count: + _logger.warning(f"Filtered {duplicate_count} duplicate imported blocks for chain {chain_id}") + return [latest_by_height[height] for height in sorted(latest_by_height)] + +@router.get("/export-chain", summary="Export full chain state") +@rate_limit(rate=200, per=60) +async def export_chain( + request: Request, chain_id: str = None +) -> Dict[str, Any]: + """Export full chain state as JSON for manual synchronization""" + chain_id = get_chain_id(chain_id) + try: + # Use session_scope for database operations + with session_scope() as session: + blocks = _select_export_blocks(session, chain_id) + + accounts_result = session.execute( + select(Account) + .where(Account.chain_id == chain_id) + .order_by(Account.address) + ) + accounts = list(accounts_result.scalars().all()) + + txs_result = session.execute( + select(Transaction) + .where(Transaction.chain_id == chain_id) + .order_by(Transaction.block_height, Transaction.id) + ) + transactions = list(txs_result.scalars().all()) + + # Build export data + export_data = { + "chain_id": chain_id, + "export_timestamp": datetime.now().isoformat(), + "block_count": len(blocks), + "account_count": len(accounts), + "transaction_count": len(transactions), + "blocks": [ + { + "chain_id": b.chain_id, + "height": b.height, + "hash": b.hash, + "parent_hash": b.parent_hash, + "proposer": b.proposer, + "timestamp": b.timestamp.isoformat() if b.timestamp else None, + "state_root": b.state_root, + "tx_count": b.tx_count, + "block_metadata": b.block_metadata, + } + for b in blocks + ], + "accounts": [ + { + "chain_id": a.chain_id, + "address": a.address, + "balance": a.balance, + "nonce": a.nonce + } + for a in accounts + ], + "transactions": [ + { + "id": t.id, + "chain_id": t.chain_id, + "tx_hash": t.tx_hash, + "block_height": t.block_height, + "sender": t.sender, + "recipient": t.recipient, + "payload": t.payload, + "value": t.value, + "fee": t.fee, + "nonce": t.nonce, + "timestamp": _serialize_optional_timestamp(t.timestamp), + "status": t.status, + "created_at": t.created_at.isoformat() if t.created_at else None, + "tx_metadata": t.tx_metadata, + } + for t in transactions + ] + } + + return { + "success": True, + "export_data": export_data, + "export_size_bytes": len(json.dumps(export_data)) + } + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error exporting chain: {e}") + raise HTTPException(status_code=500, detail=f"Failed to export chain: {str(e)}") + +@router.post("/import-chain", summary="Import chain state") +@rate_limit(rate=50, per=60) +async def import_chain( + request: Request, import_data: dict +) -> Dict[str, Any]: + """Import chain state from JSON for manual synchronization""" + async with _import_lock: + try: + chain_id = import_data.get("chain_id") + blocks = import_data.get("blocks", []) + accounts = import_data.get("accounts", []) + transactions = import_data.get("transactions", []) + + if not chain_id and blocks: + chain_id = blocks[0].get("chain_id") + chain_id = get_chain_id(chain_id) + + unique_blocks = _dedupe_import_blocks(blocks, chain_id) + + with session_scope() as session: + if not unique_blocks: + raise HTTPException(status_code=400, detail="No blocks to import") + + existing_blocks = session.execute( + select(Block) + .where(Block.chain_id == chain_id) + .order_by(Block.height) + ) + existing_count = len(list(existing_blocks.scalars().all())) + + if existing_count > 0: + _logger.info(f"Backing up existing chain with {existing_count} blocks") + + _logger.info(f"Clearing existing transactions for chain {chain_id}") + session.execute(delete(Transaction).where(Transaction.chain_id == chain_id)) + if accounts: + _logger.info(f"Clearing existing accounts for chain {chain_id}") + session.execute(delete(Account).where(Account.chain_id == chain_id)) + _logger.info(f"Clearing existing blocks for chain {chain_id}") + session.execute(delete(Block).where(Block.chain_id == chain_id)) + + import_hashes = {block_data["hash"] for block_data in unique_blocks} + if import_hashes: + hash_conflict_result = session.execute( + select(Block.hash, Block.chain_id) + .where(Block.hash.in_(import_hashes)) + ) + hash_conflicts = hash_conflict_result.all() + if hash_conflicts: + conflict_chains = {chain_id for _, chain_id in hash_conflicts} + _logger.warning(f"Clearing {len(hash_conflicts)} blocks with conflicting hashes across chains: {conflict_chains}") + session.execute(delete(Block).where(Block.hash.in_(import_hashes))) + + session.commit() + session.expire_all() + + _logger.info(f"Importing {len(unique_blocks)} unique blocks (filtered from {len(blocks)} total)") + + for block_data in unique_blocks: + block_timestamp = _parse_datetime_value(block_data.get("timestamp"), "block timestamp") or datetime.now(timezone.utc) + block = Block( + chain_id=chain_id, + height=block_data["height"], + hash=block_data["hash"], + parent_hash=block_data["parent_hash"], + proposer=block_data["proposer"], + timestamp=block_timestamp, + state_root=block_data.get("state_root"), + tx_count=block_data.get("tx_count", 0), + block_metadata=block_data.get("block_metadata"), + ) + session.add(block) + + for account_data in accounts: + account_chain_id = account_data.get("chain_id", chain_id) + if account_chain_id != chain_id: + raise HTTPException( + status_code=400, + detail=f"Mismatched account chain_id '{account_chain_id}' for import chain '{chain_id}'", + ) + account = Account( + chain_id=account_chain_id, + address=account_data["address"], + balance=account_data["balance"], + nonce=account_data["nonce"], + ) + session.add(account) + + for tx_data in transactions: + tx_chain_id = tx_data.get("chain_id", chain_id) + if tx_chain_id != chain_id: + raise HTTPException( + status_code=400, + detail=f"Mismatched transaction chain_id '{tx_chain_id}' for import chain '{chain_id}'", + ) + tx = Transaction( + id=tx_data.get("id"), + chain_id=tx_chain_id, + tx_hash=str(tx_data.get("tx_hash") or tx_data.get("id") or ""), + block_height=tx_data.get("block_height"), + sender=tx_data["sender"], + recipient=tx_data["recipient"], + payload=tx_data.get("payload", {}), + value=tx_data.get("value", 0), + fee=tx_data.get("fee", 0), + nonce=tx_data.get("nonce", 0), + timestamp=_serialize_optional_timestamp(tx_data.get("timestamp")), + status=tx_data.get("status", "pending"), + tx_metadata=tx_data.get("tx_metadata"), + ) + created_at = _parse_datetime_value(tx_data.get("created_at"), "transaction created_at") + if created_at is not None: + tx.created_at = created_at + session.add(tx) + + session.commit() + + return { + "success": True, + "imported_blocks": len(unique_blocks), + "imported_accounts": len(accounts), + "imported_transactions": len(transactions), + "chain_id": chain_id, + "message": f"Successfully imported {len(unique_blocks)} blocks", + } + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error importing chain: {e}") + raise HTTPException(status_code=500, detail=f"Failed to import chain: {str(e)}") + +@router.post("/force-sync", summary="Force reorg to specified peer") +@rate_limit(rate=50, per=60) +async def force_sync( + request: Request, peer_data: dict +) -> Dict[str, Any]: + """Force blockchain reorganization to sync with specified peer""" + try: + peer_url = peer_data.get("peer_url") + target_height = peer_data.get("target_height") + + if not peer_url: + raise HTTPException(status_code=400, detail="peer_url is required") + + # Validate peer_url to prevent SSRF + import re + from urllib.parse import urlparse + + parsed = urlparse(peer_url) + if not parsed.scheme or parsed.scheme not in ['http', 'https']: + raise HTTPException(status_code=400, detail="Invalid URL scheme") + + # Block private/internal IPs + hostname = parsed.hostname + if hostname: + # Block localhost and private IP ranges + if hostname in ['localhost', '127.0.0.1', '::1'] or hostname.startswith('192.168.') or hostname.startswith('10.') or hostname.startswith('172.16.'): + raise HTTPException(status_code=400, detail="Invalid peer URL") + + import requests + + response = requests.get(f"{peer_url}/rpc/export-chain", timeout=30) + + if response.status_code != 200: + raise HTTPException(status_code=400, detail=f"Failed to fetch peer chain: {response.status_code}") + + peer_chain_data = response.json() + peer_blocks = peer_chain_data["export_data"]["blocks"] + + if target_height and len(peer_blocks) < target_height: + raise HTTPException(status_code=400, detail=f"Peer only has {len(peer_blocks)} blocks, cannot sync to height {target_height}") + + import_result = await import_chain(peer_chain_data["export_data"]) + + return { + "success": True, + "synced_from": peer_url, + "synced_blocks": import_result["imported_blocks"], + "target_height": target_height or import_result["imported_blocks"], + "message": f"Successfully synced with peer {peer_url}" + } + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error forcing sync: {e}") + raise HTTPException(status_code=500, detail=f"Failed to force sync: {str(e)}") + + +class GetLogsRequest(BaseModel): + """Request model for eth_getLogs RPC endpoint.""" + address: Optional[str] = Field(None, description="Contract address to filter logs") + from_block: Optional[int] = Field(None, description="Starting block height") + to_block: Optional[int] = Field(None, description="Ending block height") + topics: Optional[List[str]] = Field(None, description="Event topics to filter") + + +class LogEntry(BaseModel): + """Single log entry from smart contract event.""" + address: str + topics: List[str] + data: str + block_number: int + transaction_hash: str + log_index: int + + +class GetLogsResponse(BaseModel): + """Response model for eth_getLogs RPC endpoint.""" + logs: List[LogEntry] + count: int + + +@router.post("/eth_getLogs", summary="Query smart contract event logs") +@rate_limit(rate=200, per=60) +async def get_logs( + request: Request, + logs_request: GetLogsRequest, + chain_id: Optional[str] = None +) -> GetLogsResponse: + """ + Query smart contract event logs using eth_getLogs-compatible endpoint. + Filters Receipt model for logs matching contract address and event topics. + """ + chain_id = get_chain_id(chain_id) + + with session_scope() as session: + # Build query for receipts + query = select(Receipt).where(Receipt.chain_id == chain_id) + + # Filter by block range + if request.from_block is not None: + query = query.where(Receipt.block_height >= request.from_block) + if request.to_block is not None: + query = query.where(Receipt.block_height <= request.to_block) + + # Execute query + receipts = session.execute(query).scalars().all() + + logs = [] + for receipt in receipts: + # Extract event logs from receipt payload + payload = receipt.payload or {} + events = payload.get("events", []) + + for event in events: + # Filter by contract address if specified + if request.address and event.get("address") != request.address: + continue + + # Filter by topics if specified + if request.topics: + event_topics = event.get("topics", []) + if not any(topic in event_topics for topic in request.topics): + continue + + # Create log entry + log_entry = LogEntry( + address=event.get("address", ""), + topics=event.get("topics", []), + data=str(event.get("data", "")), + block_number=receipt.block_height or 0, + transaction_hash=receipt.receipt_id, + log_index=event.get("logIndex", 0) + ) + logs.append(log_entry) + + return GetLogsResponse(logs=logs, count=len(logs)) + + +# Island Management Endpoints for Edge API +class JoinIslandRequest(BaseModel): + """Request model for joining an island""" + island_id: str + island_name: str + chain_id: str + role: str = "compute-provider" + is_hub: bool = False + + +class JoinIslandResponse(BaseModel): + """Response model for joining an island""" + success: bool + island_id: str + status: str + message: str + + +class LeaveIslandRequest(BaseModel): + """Request model for leaving an island""" + island_id: str + + +class LeaveIslandResponse(BaseModel): + """Response model for leaving an island""" + success: bool + island_id: str + status: str + message: str + + +class BridgeRequestRequest(BaseModel): + """Request model for requesting a bridge""" + target_island_id: str + + +class BridgeRequestResponse(BaseModel): + """Response model for bridge request""" + success: bool + request_id: str + target_island_id: str + status: str + message: str + + +# Dispute Resolution Endpoints +class FileDisputeRequest(BaseModel): + """Request model for filing a dispute""" + agreement_id: int = Field(description="ID of the agreement being disputed") + respondent: str = Field(description="Address of the respondent") + dispute_type: str = Field(description="Type of dispute (Performance, Payment, ServiceQuality, Availability, Other)") + reason: str = Field(description="Reason for the dispute") + evidence_hash: str = Field(description="Hash of initial evidence") + + +class FileDisputeResponse(BaseModel): + """Response model for filing a dispute""" + success: bool + dispute_id: int + status: str + message: str + + +class SubmitEvidenceRequest(BaseModel): + """Request model for submitting evidence""" + dispute_id: int = Field(description="ID of the dispute") + evidence_type: str = Field(description="Type of evidence") + evidence_data: str = Field(description="Evidence data (IPFS hash, URL, etc.)") + + +class SubmitEvidenceResponse(BaseModel): + """Response model for submitting evidence""" + success: bool + evidence_id: int + status: str + message: str + + +class VerifyEvidenceRequest(BaseModel): + """Request model for verifying evidence""" + dispute_id: int = Field(description="ID of the dispute") + evidence_id: int = Field(description="ID of the evidence") + is_valid: bool = Field(description="Whether the evidence is valid") + verification_score: int = Field(description="Verification score (0-100)") + + +class VerifyEvidenceResponse(BaseModel): + """Response model for verifying evidence""" + success: bool + status: str + message: str + + +class SubmitArbitrationVoteRequest(BaseModel): + """Request model for submitting arbitration vote""" + dispute_id: int = Field(description="ID of the dispute") + vote_in_favor_of_initiator: bool = Field(description="Vote for initiator") + confidence: int = Field(description="Confidence level (0-100)") + reasoning: str = Field(description="Reasoning for the vote") + + +class SubmitArbitrationVoteResponse(BaseModel): + """Response model for submitting arbitration vote""" + success: bool + status: str + message: str + + +class AuthorizeArbitratorRequest(BaseModel): + """Request model for authorizing an arbitrator""" + arbitrator: str = Field(description="Address of the arbitrator") + reputation_score: int = Field(description="Initial reputation score") + + +class AuthorizeArbitratorResponse(BaseModel): + """Response model for authorizing an arbitrator""" + success: bool + status: str + message: str + + +class GetDisputeResponse(BaseModel): + """Response model for getting dispute details""" + dispute_id: int + agreement_id: int + initiator: str + respondent: str + status: str + dispute_type: str + reason: str + evidence_hash: str + filing_time: int + evidence_deadline: int + arbitration_deadline: int + resolution_amount: int + winner: str + resolution_reason: str + arbitrator_count: int + is_escalated: bool + escalation_level: int + + +class GetEvidenceResponse(BaseModel): + """Response model for getting dispute evidence""" + evidence_id: int + dispute_id: int + submitter: str + evidence_type: str + evidence_data: str + evidence_hash: str + submission_time: int + is_valid: bool + verification_score: int + verified_by: str + + +class GetArbitrationVotesResponse(BaseModel): + """Response model for getting arbitration votes""" + dispute_id: int + arbitrator: str + vote_in_favor_of_initiator: bool + confidence: int + reasoning: str + vote_time: int + is_valid: bool + + +@router.post("/disputes/file", summary="File a new dispute") +async def file_dispute( + request: FileDisputeRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> FileDisputeResponse: + """ + File a new dispute for a marketplace transaction. + This interacts with the DisputeResolution smart contract. + """ + try: + # Get authenticated address from request + sender_address = get_authenticated_address(http_request, credentials) + + # Use dispute resolution service + result = dispute_resolution_service.file_dispute( + agreement_id=request.agreement_id, + respondent=request.respondent, + dispute_type=request.dispute_type, + reason=request.reason, + evidence_hash=request.evidence_hash, + sender_address=sender_address + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to file dispute")) + + return FileDisputeResponse( + success=True, + dispute_id=result["dispute_id"], + status=result["status"], + message=result["message"] + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error filing dispute: {e}") + raise HTTPException(status_code=500, detail=f"Failed to file dispute: {str(e)}") + + +@router.post("/disputes/evidence", summary="Submit evidence for a dispute") +async def submit_evidence( + request: SubmitEvidenceRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> SubmitEvidenceResponse: + """ + Submit evidence for a dispute. + This interacts with the DisputeResolution smart contract. + """ + try: + # Get authenticated address from request + submitter_address = get_authenticated_address(http_request, credentials) + + result = dispute_resolution_service.submit_evidence( + dispute_id=request.dispute_id, + evidence_type=request.evidence_type, + evidence_data=request.evidence_data, + submitter_address=submitter_address + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to submit evidence")) + + return SubmitEvidenceResponse( + success=True, + evidence_id=result["evidence_id"], + status=result["status"], + message=result["message"] + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error submitting evidence: {e}") + raise HTTPException(status_code=500, detail=f"Failed to submit evidence: {str(e)}") + + +@router.post("/disputes/verify-evidence", summary="Verify evidence (arbitrator only)") +async def verify_evidence( + request: VerifyEvidenceRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> VerifyEvidenceResponse: + """ + Verify evidence submitted in a dispute. + This can only be called by authorized arbitrators. + """ + try: + # Get authenticated address from request + arbitrator_address = get_authenticated_address(http_request, credentials) + + result = dispute_resolution_service.verify_evidence( + dispute_id=request.dispute_id, + evidence_id=request.evidence_id, + is_valid=request.is_valid, + verification_score=request.verification_score, + arbitrator_address=arbitrator_address + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to verify evidence")) + + return VerifyEvidenceResponse( + success=True, + status=result["status"], + message=result["message"] + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error verifying evidence: {e}") + raise HTTPException(status_code=500, detail=f"Failed to verify evidence: {str(e)}") + + +@router.post("/disputes/vote", summary="Submit arbitration vote (arbitrator only)") +async def submit_arbitration_vote( + request: SubmitArbitrationVoteRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> SubmitArbitrationVoteResponse: + """ + Submit an arbitration vote for a dispute. + This can only be called by authorized arbitrators assigned to the dispute. + """ + try: + # Get authenticated address from request + arbitrator_address = get_authenticated_address(http_request, credentials) + + # Reject zero address in all modes - this is a sensitive arbitration operation + if arbitrator_address == "0x0000000000000000000000000000000000000000": + _logger.error("Vote submission attempted with zero address - rejected") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Zero address is not allowed for arbitration operations" + ) + + return SubmitArbitrationVoteResponse( + success=True, + status="Submitted", + message=f"Vote submitted successfully for dispute {request.dispute_id}" + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error submitting arbitration vote: {e}") + raise HTTPException(status_code=500, detail=f"Failed to submit vote: {str(e)}") + + +@router.post("/disputes/arbitrators/authorize", summary="Authorize an arbitrator (admin only)") +async def authorize_arbitrator( + request: AuthorizeArbitratorRequest, + http_request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> AuthorizeArbitratorResponse: + """ + Authorize a new arbitrator. + This can only be called by the contract owner. + """ + try: + # Get authenticated address from request + owner_address = get_authenticated_address(http_request, credentials) + + result = dispute_resolution_service.authorize_arbitrator( + arbitrator_address=request.arbitrator, + reputation_score=request.reputation_score, + owner_address=owner_address + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to authorize arbitrator")) + + return AuthorizeArbitratorResponse( + success=True, + status=result["status"], + message=result["message"] + ) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error authorizing arbitrator: {e}") + raise HTTPException(status_code=500, detail=f"Failed to authorize arbitrator: {str(e)}") + + +@router.get("/disputes/active", summary="Get all active disputes") +async def get_active_disputes() -> Dict[str, Any]: + """ + Get all active disputes. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_active_disputes() + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get active disputes")) + + return result + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting active disputes: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get active disputes: {str(e)}") + + +@router.get("/disputes/arbitrators", summary="Get all authorized arbitrators") +async def get_authorized_arbitrators() -> Dict[str, Any]: + """ + Get all authorized arbitrators. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_authorized_arbitrators() + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get authorized arbitrators")) + + return result + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting authorized arbitrators: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get authorized arbitrators: {str(e)}") + + +@router.get("/disputes/arbitrators/{arbitrator_address}", summary="Get disputes for an arbitrator") +async def get_arbitrator_disputes(arbitrator_address: str) -> Dict[str, Any]: + """ + Get all disputes assigned to an arbitrator. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_arbitrator_disputes(arbitrator_address) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get arbitrator disputes")) + + return result + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting arbitrator disputes: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get arbitrator disputes: {str(e)}") + + +@router.get("/disputes/user/{user_address}", summary="Get disputes for a user") +async def get_user_disputes(user_address: str) -> Dict[str, Any]: + """ + Get all disputes for a specific user. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_user_disputes(user_address) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get user disputes")) + + return result + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting user disputes: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get user disputes: {str(e)}") + + +@router.get("/disputes/{dispute_id}", summary="Get dispute details") +async def get_dispute(dispute_id: int) -> GetDisputeResponse: + """ + Get details of a specific dispute. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_dispute(dispute_id) + + if not result.get("success"): + raise HTTPException(status_code=404, detail=result.get("error", "Dispute not found")) + + dispute_data = result["dispute"] + return GetDisputeResponse(**dispute_data) + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting dispute: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get dispute: {str(e)}") + + +@router.get("/disputes/{dispute_id}/evidence", summary="Get evidence for a dispute") +async def get_dispute_evidence(dispute_id: int) -> List[GetEvidenceResponse]: + """ + Get all evidence submitted for a dispute. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_dispute_evidence(dispute_id) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get dispute evidence")) + + return [GetEvidenceResponse(**e) for e in result["evidence"]] + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting dispute evidence: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get dispute evidence: {str(e)}") + + +@router.get("/disputes/{dispute_id}/votes", summary="Get arbitration votes for a dispute") +async def get_arbitration_votes(dispute_id: int) -> List[GetArbitrationVotesResponse]: + """ + Get all arbitration votes for a dispute. + This retrieves information from the DisputeResolution smart contract. + """ + try: + result = dispute_resolution_service.get_arbitration_votes(dispute_id) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to get arbitration votes")) + + return [GetArbitrationVotesResponse(**v) for v in result["votes"]] + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error getting arbitration votes: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get arbitration votes: {str(e)}") + + +@router.post("/islands/join", summary="Join an island") +async def join_island(request: JoinIslandRequest) -> JoinIslandResponse: + """ + Join an island for edge compute operations. + Calls IslandManager.join_island to register the node as a member of the specified island. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + success = island_manager.join_island( + island_id=request.island_id, + island_name=request.island_name, + chain_id=request.chain_id, + is_hub=request.is_hub + ) + + if success: + return JoinIslandResponse( + success=True, + island_id=request.island_id, + status="joined", + message=f"Successfully joined island {request.island_id}" + ) + else: + return JoinIslandResponse( + success=False, + island_id=request.island_id, + status="failed", + message=f"Failed to join island {request.island_id} (may already be a member)" + ) + + +@router.post("/islands/leave", summary="Leave an island") +async def leave_island(request: LeaveIslandRequest) -> LeaveIslandResponse: + """ + Leave an island. + Calls IslandManager.leave_island to remove the node from the specified island. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + success = island_manager.leave_island(request.island_id) + + if success: + return LeaveIslandResponse( + success=True, + island_id=request.island_id, + status="left", + message=f"Successfully left island {request.island_id}" + ) + else: + return LeaveIslandResponse( + success=False, + island_id=request.island_id, + status="failed", + message=f"Failed to leave island {request.island_id} (may not be a member)" + ) + + +@router.get("/islands", summary="List all islands") +@rate_limit(rate=100, per=60) +async def list_islands() -> Dict[str, Any]: + """ + List all islands that the node is a member of. + Calls IslandManager.get_all_islands to retrieve island memberships. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + islands = island_manager.get_all_islands() + + return { + "islands": [ + { + "island_id": island.island_id, + "island_name": island.island_name, + "chain_id": island.chain_id, + "status": island.status.value, + "role": getattr(island, 'role', 'unknown'), + "peer_count": island.peer_count, + "is_hub": island.is_hub, + "joined_at": island.joined_at + } + for island in islands + ], + "total": len(islands) + } + + +@router.get("/islands/{island_id}", summary="Get island details") +@rate_limit(rate=100, per=60) +async def get_island(island_id: str) -> Dict[str, Any]: + """ + Get details about a specific island. + Calls IslandManager.get_island_info to retrieve island membership details. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + island = island_manager.get_island_info(island_id) + + if island is None: + raise HTTPException(status_code=404, detail=f"Island {island_id} not found") + + return { + "island_id": island.island_id, + "island_name": island.island_name, + "chain_id": island.chain_id, + "status": island.status.value, + "role": getattr(island, 'role', 'unknown'), + "peer_count": island.peer_count, + "is_hub": island.is_hub, + "joined_at": island.joined_at + } + + +@router.post("/islands/bridge", summary="Request a bridge to another island") +async def request_bridge(request: BridgeRequestRequest) -> BridgeRequestResponse: + """ + Request a bridge to another island for cross-island communication. + Calls IslandManager.request_bridge to initiate a bridge request. + """ + island_manager = get_island_manager() + if island_manager is None: + raise HTTPException(status_code=503, detail="Island manager not available") + + request_id = island_manager.request_bridge(request.target_island_id) + + if request_id: + return BridgeRequestResponse( + success=True, + request_id=request_id, + target_island_id=request.target_island_id, + status="pending", + message=f"Bridge request {request_id} submitted for {request.target_island_id}" + ) + else: + return BridgeRequestResponse( + success=False, + request_id="", + target_island_id=request.target_island_id, + status="failed", + message=f"Failed to request bridge to {request.target_island_id} (may already be a member)" + ) + + +@router.get("/accounts/{address}", summary="Get account details") +@rate_limit(rate=200, per=60) +async def get_account( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """ + Get account details including balance and nonce. + + Args: + address: The account address + chain_id: Optional chain ID (defaults to node's chain) + + Returns: + Account details or 404 if not found + """ + chain_id = get_chain_id(chain_id) + address = address.lower().strip() + + with session_scope() as session: + account = session.get(Account, (chain_id, address)) + if not account: + raise HTTPException(status_code=404, detail=f"Account {address} not found on chain {chain_id}") + + return { + "success": True, + "address": account.address, + "chain_id": account.chain_id, + "balance": account.balance, + "nonce": account.nonce, + "updated_at": account.updated_at.isoformat() if account.updated_at else None + } + + +@router.post("/register-account", summary="Create/register a new account on the blockchain") +@rate_limit(rate=100, per=60) +async def create_account( + request: Request, + account_data: dict +) -> Dict[str, Any]: + """ + Create or register a new account on the blockchain. + + This endpoint allows wallets to register their public keys as accounts + on the blockchain, enabling them to send and receive transactions. + + Args: + account_data: Dictionary containing: + - address: The account address/public key (hex string) + - chain_id: Optional chain ID (defaults to node's chain) + + Returns: + Dictionary with success status and account details + """ + chain_id = get_chain_id(account_data.get("chain_id")) + address = account_data.get("address") + + if not address: + raise HTTPException(status_code=400, detail="address is required") + + # Normalize address (ensure lowercase hex) + address = address.lower().strip() + if not address.startswith("0x"): + address = "0x" + address + + # Validate address format (should be hex) + if not all(c in "0123456789abcdef" for c in address[2:]): + raise HTTPException(status_code=400, detail="address must be a valid hex string") + + with session_scope() as session: + # Check if account already exists + existing_account = session.get(Account, (chain_id, address)) + if existing_account: + return { + "success": True, + "address": address, + "chain_id": chain_id, + "balance": existing_account.balance, + "nonce": existing_account.nonce, + "created": False, + "message": "Account already exists" + } + + # Create new account with zero balance + new_account = Account( + chain_id=chain_id, + address=address, + balance=0, + nonce=0 + ) + session.add(new_account) + session.commit() + + _logger.info(f"Created new account: address={address}, chain_id={chain_id}") + + return { + "success": True, + "address": address, + "chain_id": chain_id, + "balance": 0, + "nonce": 0, + "created": True, + "message": "Account created successfully" + } + + +@router.post("/faucet", summary="Request test tokens from faucet") +@rate_limit(rate=10, per=3600) # 10 requests per hour per IP +async def faucet_request( + request: Request, + faucet_data: dict +) -> Dict[str, Any]: + """ + Request test tokens from the blockchain faucet. + + This endpoint allows newly created wallets to receive initial funds + for testing and development purposes. + + Args: + faucet_data: Dictionary containing: + - address: The account address to fund + - amount: Optional amount to request (default: 1000000) + - chain_id: Optional chain ID (defaults to node's chain) + + Returns: + Dictionary with success status and transaction details + """ + chain_id = get_chain_id(faucet_data.get("chain_id")) + address = faucet_data.get("address") + amount = faucet_data.get("amount", 1000000) # Default 1M units + + if not address: + raise HTTPException(status_code=400, detail="address is required") + + # Normalize address + address = address.lower().strip() + if not address.startswith("0x"): + address = "0x" + address + + # Validate address format + if not all(c in "0123456789abcdef" for c in address[2:]): + raise HTTPException(status_code=400, detail="address must be a valid hex string") + + # Cap max faucet amount + if amount > 10000000: # Max 10M per request + amount = 10000000 + + with session_scope() as session: + # Check if account exists + account = session.get(Account, (chain_id, address)) + if not account: + # Auto-create account if it doesn't exist + account = Account(chain_id=chain_id, address=address, balance=0, nonce=0) + session.add(account) + session.flush() + _logger.info(f"Faucet auto-created account: {address}") + + # Generate faucet transaction (special minting transaction) + timestamp = datetime.now(timezone.utc) + tx_hash = hashlib.sha256( + f"faucet:{address}:{amount}:{timestamp.isoformat()}:{uuid.uuid4()}".encode() + ).hexdigest() + + # Apply balance update directly (faucet is special system tx) + account.balance += amount + session.add(account) + + # Create faucet transaction record + faucet_tx = Transaction( + chain_id=chain_id, + tx_hash=tx_hash, + sender="faucet", + recipient=address, + payload={"type": "FAUCET", "amount": amount, "reason": "test_funding"}, + value=amount, + fee=0, + nonce=0, + timestamp=timestamp, + block_height=None, # Not in a block - direct system tx + status="confirmed", + type="FAUCET" + ) + session.add(faucet_tx) + session.commit() + + _logger.info(f"Faucet funded {address} with {amount} units on {chain_id}") + + return { + "success": True, + "tx_hash": tx_hash, + "address": address, + "amount": amount, + "chain_id": chain_id, + "new_balance": account.balance, + "message": f"Successfully funded {address} with {amount} units" + } + + +@router.post("/bridge/lock", summary="Lock funds for cross-chain transfer") +@rate_limit(rate=20, per=60) +async def bridge_lock( + request: Request, + lock_data: dict +) -> Dict[str, Any]: + """ + Initiate a cross-chain bridge transfer by locking funds. + + This is step 1 of the atomic bridge: + 1. Lock funds on source chain (this endpoint) + 2. Generate proof + 3. Confirm on target chain + """ + try: + from ..cross_chain.bridge import get_cross_chain_bridge + bridge = get_cross_chain_bridge() + + if not bridge: + raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") + + source_chain = lock_data.get("source_chain", get_chain_id(None)) + target_chain = lock_data.get("target_chain") + sender = lock_data.get("sender") + recipient = lock_data.get("recipient") + amount = lock_data.get("amount", 0) + asset = lock_data.get("asset", "native") + + if not all([target_chain, sender, recipient]): + raise HTTPException(status_code=400, detail="Missing required fields: target_chain, sender, recipient") + + if amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + + # Execute lock + transfer = bridge.initiate_transfer( + source_chain=source_chain, + target_chain=target_chain, + sender=sender.lower(), + recipient=recipient.lower(), + amount=amount, + asset=asset + ) + + return { + "success": True, + "transfer_id": transfer.transfer_id, + "status": transfer.status.value, + "source_chain": source_chain, + "target_chain": target_chain, + "sender": sender, + "recipient": recipient, + "amount": amount, + "fee": (amount * 10) // 10000, # 0.1% fee + "lock_time": transfer.lock_time.isoformat() if transfer.lock_time else None, + "message": "Funds locked successfully. Use /bridge/confirm to complete." + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _logger.error(f"Bridge lock failed: {e}") + raise HTTPException(status_code=500, detail=f"Bridge lock failed: {str(e)}") + + +@router.post("/bridge/confirm", summary="Confirm and release cross-chain transfer") +@rate_limit(rate=20, per=60) +async def bridge_confirm( + request: Request, + confirm_data: dict +) -> Dict[str, Any]: + """ + Confirm a cross-chain bridge transfer and release funds. + + This is step 2 of the atomic bridge: + 1. Validate proof of lock + 2. Release funds on target chain + 3. Mark transfer as complete + """ + try: + from ..cross_chain.bridge import get_cross_chain_bridge + bridge = get_cross_chain_bridge() + + if not bridge: + raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") + + transfer_id = confirm_data.get("transfer_id") + proof = confirm_data.get("proof") + + if not transfer_id or not proof: + raise HTTPException(status_code=400, detail="Missing required fields: transfer_id, proof") + + # Execute confirmation + transfer = bridge.confirm_transfer(transfer_id, proof) + + return { + "success": True, + "transfer_id": transfer.transfer_id, + "status": transfer.status.value, + "source_chain": transfer.source_chain, + "target_chain": transfer.target_chain, + "sender": transfer.sender, + "recipient": transfer.recipient, + "amount": transfer.amount, + "target_tx_hash": transfer.target_tx_hash, + "confirm_time": transfer.confirm_time.isoformat() if transfer.confirm_time else None, + "message": "Cross-chain transfer completed successfully" + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _logger.error(f"Bridge confirm failed: {e}") + raise HTTPException(status_code=500, detail=f"Bridge confirm failed: {str(e)}") + + +@router.get("/bridge/transfer/{transfer_id}", summary="Get transfer status") +@rate_limit(rate=100, per=60) +async def get_bridge_transfer( + request: Request, + transfer_id: str +) -> Dict[str, Any]: + """Get the status of a cross-chain transfer""" + try: + from ..cross_chain.bridge import get_cross_chain_bridge + bridge = get_cross_chain_bridge() + + if not bridge: + raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") + + transfer = bridge.get_transfer(transfer_id) + if not transfer: + raise HTTPException(status_code=404, detail=f"Transfer {transfer_id} not found") + + return { + "success": True, + "transfer_id": transfer.transfer_id, + "status": transfer.status.value, + "source_chain": transfer.source_chain, + "target_chain": transfer.target_chain, + "sender": transfer.sender, + "recipient": transfer.recipient, + "amount": transfer.amount, + "asset": transfer.asset, + "source_tx_hash": transfer.source_tx_hash, + "target_tx_hash": transfer.target_tx_hash, + "lock_time": transfer.lock_time.isoformat() if transfer.lock_time else None, + "confirm_time": transfer.confirm_time.isoformat() if transfer.confirm_time else None + } + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Get bridge transfer failed: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get transfer: {str(e)}") + + +@router.get("/bridge/pending", summary="List pending bridge transfers") +@rate_limit(rate=50, per=60) +async def list_pending_transfers( + request: Request, + chain_id: str = None +) -> List[Dict[str, Any]]: + """List all pending cross-chain transfers""" + try: + from ..cross_chain.bridge import get_cross_chain_bridge + bridge = get_cross_chain_bridge() + + if not bridge: + raise HTTPException(status_code=503, detail="Cross-chain bridge not initialized") + + chain_id = get_chain_id(chain_id) + transfers = bridge.list_pending_transfers(chain_id) + + return [ + { + "transfer_id": t.transfer_id, + "source_chain": t.source_chain, + "target_chain": t.target_chain, + "sender": t.sender, + "recipient": t.recipient, + "amount": t.amount, + "status": t.status.value, + "lock_time": t.lock_time.isoformat() if t.lock_time else None + } + for t in transfers + ] + + except Exception as e: + _logger.error(f"List pending transfers failed: {e}") + raise HTTPException(status_code=500, detail=f"Failed to list transfers: {str(e)}") + + +@router.post("/staking/stake", summary="Stake tokens") +@rate_limit(rate=20, per=60) +async def stake_tokens( + request: Request, + stake_data: dict +) -> Dict[str, Any]: + """ + Stake tokens for consensus participation. + + Locks tokens for a specified period. Staked tokens earn rewards + and provide voting power in consensus. + """ + chain_id = get_chain_id(stake_data.get("chain_id")) + address = stake_data.get("address") + amount = stake_data.get("amount", 0) + lock_days = stake_data.get("lock_days", 30) + + if not address: + raise HTTPException(status_code=400, detail="address is required") + + if amount <= 0: + raise HTTPException(status_code=400, detail="amount must be positive") + + # Normalize address + address = address.lower().strip() + if not address.startswith("0x"): + address = "0x" + address + + with session_scope() as session: + # Get account + account = session.get(Account, (chain_id, address)) + if not account: + raise HTTPException(status_code=404, detail=f"Account {address} not found") + + if account.balance < amount: + raise HTTPException( + status_code=400, + detail=f"Insufficient balance: {account.balance} < {amount}" + ) + + # Lock tokens (deduct from balance) + account.balance -= amount + session.add(account) + + # Calculate lock period + locked_until = datetime.now(timezone.utc) + locked_until = locked_until.replace(day=locked_until.day + lock_days) + + # Create stake record + stake = Stake( + chain_id=chain_id, + address=address, + amount=amount, + locked_until=locked_until, + status="active" + ) + session.add(stake) + session.commit() + + _logger.info(f"Tokens staked: {address} staked {amount} on {chain_id}") + + return { + "success": True, + "stake_id": stake.id, + "address": address, + "amount": amount, + "chain_id": chain_id, + "locked_until": locked_until.isoformat(), + "status": "active", + "remaining_balance": account.balance + } + + +@router.post("/staking/unstake", summary="Unstake tokens") +@rate_limit(rate=10, per=60) +async def unstake_tokens( + request: Request, + unstake_data: dict +) -> Dict[str, Any]: + """ + Unstake tokens after lock period expires. + + Returns staked tokens to account balance. + """ + chain_id = get_chain_id(unstake_data.get("chain_id")) + address = unstake_data.get("address") + stake_id = unstake_data.get("stake_id") + + if not address or not stake_id: + raise HTTPException(status_code=400, detail="address and stake_id are required") + + # Normalize address + address = address.lower().strip() + if not address.startswith("0x"): + address = "0x" + address + + with session_scope() as session: + # Get stake record + stake = session.get(Stake, stake_id) + if not stake: + raise HTTPException(status_code=404, detail=f"Stake {stake_id} not found") + + if stake.address != address: + raise HTTPException(status_code=403, detail="Not authorized to unstake") + + if stake.status != "active": + raise HTTPException(status_code=400, detail=f"Stake is not active: {stake.status}") + + # Check if lock period expired + now = datetime.now(timezone.utc) + if stake.locked_until and now < stake.locked_until: + raise HTTPException( + status_code=400, + detail=f"Lock period not expired. Locked until: {stake.locked_until.isoformat()}" + ) + + # Return tokens to account + account = session.get(Account, (chain_id, address)) + if not account: + # Account was deleted, recreate + account = Account(chain_id=chain_id, address=address, balance=0, nonce=0) + session.add(account) + + account.balance += stake.amount + session.add(account) + + # Update stake status + stake.status = "withdrawn" + session.add(stake) + session.commit() + + _logger.info(f"Tokens unstaked: {address} recovered {stake.amount} from stake {stake_id}") + + return { + "success": True, + "stake_id": stake_id, + "address": address, + "amount": stake.amount, + "chain_id": chain_id, + "new_balance": account.balance, + "status": "withdrawn" + } + + +@router.get("/staking/{address}", summary="Get staking info") +@rate_limit(rate=100, per=60) +async def get_staking_info( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """Get staking information for an address""" + chain_id = get_chain_id(chain_id) + address = address.lower().strip() + + with session_scope() as session: + from sqlalchemy import select, func + + # Get all stakes for address + statement = select(Stake).where( + Stake.chain_id == chain_id, + Stake.address == address + ) + stakes = session.exec(statement).all() + + total_staked = sum(s.amount for s in stakes if s.status == "active") + active_stakes = [ + { + "stake_id": s.id, + "amount": s.amount, + "locked_until": s.locked_until.isoformat() if s.locked_until else None, + "status": s.status, + "created_at": s.created_at.isoformat() if s.created_at else None + } + for s in stakes if s.status == "active" + ] + + return { + "success": True, + "address": address, + "chain_id": chain_id, + "total_staked": total_staked, + "active_stake_count": len(active_stakes), + "active_stakes": active_stakes + } + + +@router.get("/balance/{address}", summary="Get detailed balance breakdown") +@rate_limit(rate=100, per=60) +async def get_balance_breakdown( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """ + Get detailed balance breakdown including: + - Available balance + - Staked amount + - Bridge-locked amount + - Total balance + """ + try: + from ..services.balance_tracker import get_balance_tracker + tracker = get_balance_tracker() + + if not tracker: + raise HTTPException(status_code=503, detail="Balance tracker not initialized") + + chain_id = get_chain_id(chain_id) + address = address.lower().strip() + + breakdown = tracker.get_balance_breakdown(address, chain_id) + return breakdown + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Failed to get balance breakdown: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get balance: {str(e)}") + + +@router.get("/balance/{address}/reconcile", summary="Reconcile balance") +@rate_limit(rate=20, per=60) +async def reconcile_balance( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """ + Reconcile account balance against all recorded operations. + + Verifies that current balance matches expected balance + based on all transactions, stakes, and bridge operations. + """ + try: + from ..services.balance_tracker import get_balance_tracker + tracker = get_balance_tracker() + + if not tracker: + raise HTTPException(status_code=503, detail="Balance tracker not initialized") + + chain_id = get_chain_id(chain_id) + address = address.lower().strip() + + result = tracker.reconcile_balance(address, chain_id) + return result + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Balance reconciliation failed: {e}") + raise HTTPException(status_code=500, detail=f"Reconciliation failed: {str(e)}") diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/staking.py b/apps/blockchain-node/src/aitbc_chain/rpc/staking.py new file mode 100644 index 00000000..5d58652a --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/staking.py @@ -0,0 +1,198 @@ +""" +Staking-related RPC endpoints. +""" + +from datetime import datetime, timezone +from typing import Any, Dict +from fastapi import HTTPException, Request +from sqlmodel import select + +from ..database import session_scope +from ..models import Account, Stake +from ..logger import get_logger +from .utils import get_chain_id +from aitbc.rate_limiting import rate_limit + +_logger = get_logger(__name__) + + +@rate_limit(rate=20, per=60) +async def stake_tokens( + request: Request, + stake_data: dict +) -> Dict[str, Any]: + """ + Stake tokens for consensus participation. + + Locks tokens for a specified period. Staked tokens earn rewards + and provide voting power in consensus. + """ + chain_id = get_chain_id(stake_data.get("chain_id")) + address = stake_data.get("address") + amount = stake_data.get("amount", 0) + lock_days = stake_data.get("lock_days", 30) + + if not address: + raise HTTPException(status_code=400, detail="address is required") + + if amount <= 0: + raise HTTPException(status_code=400, detail="amount must be positive") + + # Normalize address + address = address.lower().strip() + if not address.startswith("0x"): + address = "0x" + address + + with session_scope() as session: + # Get account + account = session.get(Account, (chain_id, address)) + if not account: + raise HTTPException(status_code=404, detail=f"Account {address} not found") + + if account.balance < amount: + raise HTTPException( + status_code=400, + detail=f"Insufficient balance: {account.balance} < {amount}" + ) + + # Lock tokens (deduct from balance) + account.balance -= amount + session.add(account) + + # Calculate lock period + locked_until = datetime.now(timezone.utc) + locked_until = locked_until.replace(day=locked_until.day + lock_days) + + # Create stake record + stake = Stake( + chain_id=chain_id, + address=address, + amount=amount, + locked_until=locked_until, + status="active" + ) + session.add(stake) + session.commit() + + _logger.info(f"Tokens staked: {address} staked {amount} on {chain_id}") + + return { + "success": True, + "stake_id": stake.id, + "address": address, + "amount": amount, + "chain_id": chain_id, + "locked_until": locked_until.isoformat(), + "status": "active", + "remaining_balance": account.balance + } + + +@rate_limit(rate=10, per=60) +async def unstake_tokens( + request: Request, + unstake_data: dict +) -> Dict[str, Any]: + """ + Unstake tokens after lock period expires. + + Returns staked tokens to account balance. + """ + chain_id = get_chain_id(unstake_data.get("chain_id")) + address = unstake_data.get("address") + stake_id = unstake_data.get("stake_id") + + if not address or not stake_id: + raise HTTPException(status_code=400, detail="address and stake_id are required") + + # Normalize address + address = address.lower().strip() + if not address.startswith("0x"): + address = "0x" + address + + with session_scope() as session: + # Get stake record + stake = session.get(Stake, stake_id) + if not stake: + raise HTTPException(status_code=404, detail=f"Stake {stake_id} not found") + + if stake.address != address: + raise HTTPException(status_code=403, detail="Not authorized to unstake") + + if stake.status != "active": + raise HTTPException(status_code=400, detail=f"Stake is not active: {stake.status}") + + # Check if lock period expired + now = datetime.now(timezone.utc) + if stake.locked_until and now < stake.locked_until: + raise HTTPException( + status_code=400, + detail=f"Lock period not expired. Locked until: {stake.locked_until.isoformat()}" + ) + + # Return tokens to account + account = session.get(Account, (chain_id, address)) + if not account: + # Account was deleted, recreate + account = Account(chain_id=chain_id, address=address, balance=0, nonce=0) + session.add(account) + + account.balance += stake.amount + session.add(account) + + # Update stake status + stake.status = "withdrawn" + session.add(stake) + session.commit() + + _logger.info(f"Tokens unstaked: {address} recovered {stake.amount} from stake {stake_id}") + + return { + "success": True, + "stake_id": stake_id, + "address": address, + "amount": stake.amount, + "chain_id": chain_id, + "new_balance": account.balance, + "status": "withdrawn" + } + + +@rate_limit(rate=100, per=60) +async def get_staking_info( + request: Request, + address: str, + chain_id: str = None +) -> Dict[str, Any]: + """Get staking information for an address""" + chain_id = get_chain_id(chain_id) + address = address.lower().strip() + + with session_scope() as session: + # Get all stakes for address + statement = select(Stake).where( + Stake.chain_id == chain_id, + Stake.address == address + ) + stakes = session.exec(statement).all() + + total_staked = sum(s.amount for s in stakes if s.status == "active") + active_stakes = [ + { + "stake_id": s.id, + "amount": s.amount, + "locked_until": s.locked_until.isoformat() if s.locked_until else None, + "status": s.status, + "created_at": s.created_at.isoformat() if s.created_at else None + } + for s in stakes if s.status == "active" + ] + + return { + "success": True, + "address": address, + "chain_id": chain_id, + "total_staked": total_staked, + "active_stake_count": len(active_stakes), + "active_stakes": active_stakes + } diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/sync.py b/apps/blockchain-node/src/aitbc_chain/rpc/sync.py new file mode 100644 index 00000000..05f17cfc --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/sync.py @@ -0,0 +1,367 @@ +""" +Sync-related RPC endpoints. +""" + +import asyncio +import json +import re +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from fastapi import HTTPException, Request +from sqlmodel import select, delete +from urllib.parse import urlparse + +from ..database import session_scope +from ..models import Account, Block, Transaction +from ..logger import get_logger +from .utils import get_chain_id +from aitbc.rate_limiting import rate_limit + +_logger = get_logger(__name__) + +# Global rate limiter for import operations +_last_import_time = 0 +_import_lock = asyncio.Lock() + + +def _serialize_optional_timestamp(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + return value + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value) + + +def _parse_datetime_value(value: Any, field_name: str) -> Optional[datetime]: + if value in (None, ""): + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {field_name}: {value}") from exc + raise HTTPException(status_code=400, detail=f"Invalid {field_name} type: {type(value).__name__}") + + +def _select_export_blocks(session, chain_id: str) -> List[Block]: + blocks_result = session.execute( + select(Block) + .where(Block.chain_id == chain_id) + .order_by(Block.height.asc(), Block.id.desc()) + ) + blocks: List[Block] = [] + seen_heights = set() + duplicate_count = 0 + for block in blocks_result.scalars().all(): + if block.height in seen_heights: + duplicate_count += 1 + continue + seen_heights.add(block.height) + blocks.append(block) + if duplicate_count: + _logger.warning(f"Filtered {duplicate_count} duplicate exported blocks for chain {chain_id}") + return blocks + + +def _dedupe_import_blocks(blocks: List[Dict[str, Any]], chain_id: str) -> List[Dict[str, Any]]: + latest_by_height: Dict[int, Dict[str, Any]] = {} + duplicate_count = 0 + for block_data in blocks: + if "height" not in block_data: + raise HTTPException(status_code=400, detail="Block height is required") + try: + height = int(block_data["height"]) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=f"Invalid block height: {block_data.get('height')}") from exc + block_chain_id = block_data.get("chain_id") + if block_chain_id and block_chain_id != chain_id: + raise HTTPException( + status_code=400, + detail=f"Mismatched block chain_id '{block_chain_id}' for import chain '{chain_id}'", + ) + normalized_block = dict(block_data) + normalized_block["height"] = height + normalized_block["chain_id"] = chain_id + if height in latest_by_height: + duplicate_count += 1 + latest_by_height[height] = normalized_block + if duplicate_count: + _logger.warning(f"Filtered {duplicate_count} duplicate imported blocks for chain {chain_id}") + return [latest_by_height[height] for height in sorted(latest_by_height)] + + +@rate_limit(rate=200, per=60) +async def export_chain( + request: Request, chain_id: str = None +) -> Dict[str, Any]: + """Export full chain state as JSON for manual synchronization""" + chain_id = get_chain_id(chain_id) + try: + with session_scope() as session: + blocks = _select_export_blocks(session, chain_id) + + accounts_result = session.execute( + select(Account) + .where(Account.chain_id == chain_id) + .order_by(Account.address) + ) + accounts = list(accounts_result.scalars().all()) + + txs_result = session.execute( + select(Transaction) + .where(Transaction.chain_id == chain_id) + .order_by(Transaction.block_height, Transaction.id) + ) + transactions = list(txs_result.scalars().all()) + + export_data = { + "chain_id": chain_id, + "export_timestamp": datetime.now().isoformat(), + "block_count": len(blocks), + "account_count": len(accounts), + "transaction_count": len(transactions), + "blocks": [ + { + "chain_id": b.chain_id, + "height": b.height, + "hash": b.hash, + "parent_hash": b.parent_hash, + "proposer": b.proposer, + "timestamp": b.timestamp.isoformat() if b.timestamp else None, + "state_root": b.state_root, + "tx_count": b.tx_count, + "block_metadata": b.block_metadata, + } + for b in blocks + ], + "accounts": [ + { + "chain_id": a.chain_id, + "address": a.address, + "balance": a.balance, + "nonce": a.nonce + } + for a in accounts + ], + "transactions": [ + { + "id": t.id, + "chain_id": t.chain_id, + "tx_hash": t.tx_hash, + "block_height": t.block_height, + "sender": t.sender, + "recipient": t.recipient, + "payload": t.payload, + "value": t.value, + "fee": t.fee, + "nonce": t.nonce, + "timestamp": _serialize_optional_timestamp(t.timestamp), + "status": t.status, + "created_at": t.created_at.isoformat() if t.created_at else None, + "tx_metadata": t.tx_metadata, + } + for t in transactions + ] + } + + return { + "success": True, + "export_data": export_data, + "export_size_bytes": len(json.dumps(export_data)) + } + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error exporting chain: {e}") + raise HTTPException(status_code=500, detail=f"Failed to export chain: {str(e)}") + + +@rate_limit(rate=50, per=60) +async def import_chain( + request: Request, import_data: dict +) -> Dict[str, Any]: + """Import chain state from JSON for manual synchronization""" + async with _import_lock: + try: + chain_id = import_data.get("chain_id") + blocks = import_data.get("blocks", []) + accounts = import_data.get("accounts", []) + transactions = import_data.get("transactions", []) + + if not chain_id and blocks: + chain_id = blocks[0].get("chain_id") + chain_id = get_chain_id(chain_id) + + unique_blocks = _dedupe_import_blocks(blocks, chain_id) + + with session_scope() as session: + if not unique_blocks: + raise HTTPException(status_code=400, detail="No blocks to import") + + existing_blocks = session.execute( + select(Block) + .where(Block.chain_id == chain_id) + .order_by(Block.height) + ) + existing_count = len(list(existing_blocks.scalars().all())) + + if existing_count > 0: + _logger.info(f"Backing up existing chain with {existing_count} blocks") + + _logger.info(f"Clearing existing transactions for chain {chain_id}") + session.execute(delete(Transaction).where(Transaction.chain_id == chain_id)) + if accounts: + _logger.info(f"Clearing existing accounts for chain {chain_id}") + session.execute(delete(Account).where(Account.chain_id == chain_id)) + _logger.info(f"Clearing existing blocks for chain {chain_id}") + session.execute(delete(Block).where(Block.chain_id == chain_id)) + + import_hashes = {block_data["hash"] for block_data in unique_blocks} + if import_hashes: + hash_conflict_result = session.execute( + select(Block.hash, Block.chain_id) + .where(Block.hash.in_(import_hashes)) + ) + hash_conflicts = hash_conflict_result.all() + if hash_conflicts: + conflict_chains = {chain_id for _, chain_id in hash_conflicts} + _logger.warning(f"Clearing {len(hash_conflicts)} blocks with conflicting hashes across chains: {conflict_chains}") + session.execute(delete(Block).where(Block.hash.in_(import_hashes))) + + session.commit() + session.expire_all() + + _logger.info(f"Importing {len(unique_blocks)} unique blocks (filtered from {len(blocks)} total)") + + for block_data in unique_blocks: + block_timestamp = _parse_datetime_value(block_data.get("timestamp"), "block timestamp") or datetime.now(timezone.utc) + block = Block( + chain_id=chain_id, + height=block_data["height"], + hash=block_data["hash"], + parent_hash=block_data["parent_hash"], + proposer=block_data["proposer"], + timestamp=block_timestamp, + state_root=block_data.get("state_root"), + tx_count=block_data.get("tx_count", 0), + block_metadata=block_data.get("block_metadata"), + ) + session.add(block) + + for account_data in accounts: + account_chain_id = account_data.get("chain_id", chain_id) + if account_chain_id != chain_id: + raise HTTPException( + status_code=400, + detail=f"Mismatched account chain_id '{account_chain_id}' for import chain '{chain_id}'", + ) + account = Account( + chain_id=account_chain_id, + address=account_data["address"], + balance=account_data["balance"], + nonce=account_data["nonce"], + ) + session.add(account) + + for tx_data in transactions: + tx_chain_id = tx_data.get("chain_id", chain_id) + if tx_chain_id != chain_id: + raise HTTPException( + status_code=400, + detail=f"Mismatched transaction chain_id '{tx_chain_id}' for import chain '{chain_id}'", + ) + tx = Transaction( + id=tx_data.get("id"), + chain_id=tx_chain_id, + tx_hash=str(tx_data.get("tx_hash") or tx_data.get("id") or ""), + block_height=tx_data.get("block_height"), + sender=tx_data["sender"], + recipient=tx_data["recipient"], + payload=tx_data.get("payload", {}), + value=tx_data.get("value", 0), + fee=tx_data.get("fee", 0), + nonce=tx_data.get("nonce", 0), + timestamp=_serialize_optional_timestamp(tx_data.get("timestamp")), + status=tx_data.get("status", "pending"), + tx_metadata=tx_data.get("tx_metadata"), + ) + created_at = _parse_datetime_value(tx_data.get("created_at"), "transaction created_at") + if created_at is not None: + tx.created_at = created_at + session.add(tx) + + session.commit() + + return { + "success": True, + "imported_blocks": len(unique_blocks), + "imported_accounts": len(accounts), + "imported_transactions": len(transactions), + "chain_id": chain_id, + "message": f"Successfully imported {len(unique_blocks)} blocks", + } + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error importing chain: {e}") + raise HTTPException(status_code=500, detail=f"Failed to import chain: {str(e)}") + + +@rate_limit(rate=50, per=60) +async def force_sync( + request: Request, peer_data: dict +) -> Dict[str, Any]: + """Force blockchain reorganization to sync with specified peer""" + try: + peer_url = peer_data.get("peer_url") + target_height = peer_data.get("target_height") + + if not peer_url: + raise HTTPException(status_code=400, detail="peer_url is required") + + # Validate peer_url to prevent SSRF + parsed = urlparse(peer_url) + if not parsed.scheme or parsed.scheme not in ['http', 'https']: + raise HTTPException(status_code=400, detail="Invalid URL scheme") + + # Block private/internal IPs + hostname = parsed.hostname + if hostname: + # Block localhost and private IP ranges + if hostname in ['localhost', '127.0.0.1', '::1'] or hostname.startswith('192.168.') or hostname.startswith('10.') or hostname.startswith('172.16.'): + raise HTTPException(status_code=400, detail="Invalid peer URL") + + import requests + + response = requests.get(f"{peer_url}/rpc/export-chain", timeout=30) + + if response.status_code != 200: + raise HTTPException(status_code=400, detail=f"Failed to fetch peer chain: {response.status_code}") + + peer_chain_data = response.json() + peer_blocks = peer_chain_data["export_data"]["blocks"] + + if target_height and len(peer_blocks) < target_height: + raise HTTPException(status_code=400, detail=f"Peer only has {len(peer_blocks)} blocks, cannot sync to height {target_height}") + + import_result = await import_chain(request, peer_chain_data["export_data"]) + + return { + "success": True, + "synced_from": peer_url, + "synced_blocks": import_result["imported_blocks"], + "target_height": target_height or import_result["imported_blocks"], + "message": f"Successfully synced with peer {peer_url}" + } + + except HTTPException: + raise + except Exception as e: + _logger.error(f"Error forcing sync: {e}") + raise HTTPException(status_code=500, detail=f"Failed to force sync: {str(e)}") diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/transactions.py b/apps/blockchain-node/src/aitbc_chain/rpc/transactions.py new file mode 100644 index 00000000..ada8529c --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/transactions.py @@ -0,0 +1,226 @@ +""" +Transaction-related RPC endpoints. +""" + +from typing import Any, Dict, List, Optional + +from fastapi import HTTPException, Request +from pydantic import BaseModel, Field, model_validator +from sqlmodel import select + +from ..database import session_scope +from ..models import Account, Transaction +from ..logger import get_logger +from .utils import get_chain_id, normalize_transaction_data +from aitbc.rate_limiting import rate_limit + +_logger = get_logger(__name__) + + +class TransactionRequest(BaseModel): + """Transaction request model""" + sender: str = Field(..., alias="from") + recipient: str = Field(..., alias="to") + amount: int + fee: int = 10 + nonce: int = 0 + type: str = "TRANSFER" + payload: Dict[str, Any] = Field(default_factory=dict) + sig: str = Field(..., alias="signature") + + @model_validator(mode="before") + @classmethod + def validate_payload(cls, values: Dict[str, Any]) -> Dict[str, Any]: + """Ensure payload contains recipient and amount""" + payload = values.get("payload", {}) + if not isinstance(payload, dict): + payload = {} + + # Set recipient/to in payload if not present + if "to" not in payload and "recipient" in values: + payload["to"] = values["recipient"] + if "amount" not in payload and "amount" in values: + payload["amount"] = values["amount"] + + values["payload"] = payload + return values + + +def _validate_transaction_admission(tx_data: Dict[str, Any], mempool: Any) -> None: + """Validate transaction can be admitted to mempool""" + from ..mempool import compute_tx_hash + + chain_id = tx_data["chain_id"] + from .utils import get_supported_chains + supported_chains = get_supported_chains() + if not chain_id: + raise ValueError("transaction.chain_id is required") + if supported_chains and chain_id not in supported_chains: + raise ValueError(f"unsupported chain_id '{chain_id}'. Supported chains: {supported_chains}") + + tx_hash = compute_tx_hash(tx_data) + + with session_scope() as session: + sender_account = session.get(Account, (chain_id, tx_data["from"])) + if sender_account is None: + raise ValueError(f"sender account not found on chain '{chain_id}'") + + total_cost = tx_data["amount"] + tx_data["fee"] + if sender_account.balance < total_cost: + raise ValueError( + f"insufficient balance for sender '{tx_data['from']}' on chain '{chain_id}': has {sender_account.balance}, needs {total_cost}" + ) + + if tx_data["nonce"] != sender_account.nonce: + raise ValueError( + f"invalid nonce for sender '{tx_data['from']}' on chain '{chain_id}': expected {sender_account.nonce}, got {tx_data['nonce']}" + ) + + +@rate_limit(rate=50, per=60) +async def submit_transaction( + request: Request, tx_data: TransactionRequest +) -> Dict[str, Any]: + """Submit a new transaction to the mempool""" + from ..mempool import get_mempool + + try: + mempool = get_mempool() + chain_id = get_chain_id(None) + + # Convert TransactionRequest to dict for normalization + tx_data_dict = { + "from": tx_data.sender, + "to": tx_data.payload.get("to"), + "amount": tx_data.payload.get("amount", tx_data.payload.get("value", 0)), + "fee": tx_data.fee, + "nonce": tx_data.nonce, + "payload": tx_data.payload, + "type": tx_data.type, + "signature": tx_data.sig + } + + tx_data_dict = normalize_transaction_data(tx_data_dict, chain_id) + _validate_transaction_admission(tx_data_dict, mempool) + + tx_hash = mempool.add(tx_data_dict, chain_id=chain_id) + + return { + "success": True, + "transaction_hash": tx_hash, + "message": "Transaction submitted to mempool" + } + except Exception as e: + _logger.error("Failed to submit transaction", extra={"error": str(e)}) + raise HTTPException(status_code=400, detail=f"Failed to submit transaction: {str(e)}") + + +@rate_limit(rate=200, per=60) +async def get_mempool( + request: Request, chain_id: str = None, limit: int = 100 +) -> Dict[str, Any]: + """Get pending transactions from mempool""" + from ..mempool import get_mempool + + try: + mempool = get_mempool() + pending_txs = mempool.get_pending_transactions(chain_id=chain_id, limit=limit) + + return { + "success": True, + "transactions": pending_txs, + "count": len(pending_txs) + } + except Exception as e: + _logger.error(f"Failed to get mempool", extra={"error": str(e)}) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get mempool: {str(e)}") + + +@rate_limit(rate=50, per=60) +async def submit_marketplace_transaction( + request: Request, tx_data: Dict[str, Any] +) -> Dict[str, Any]: + """Submit a marketplace transaction""" + from ..mempool import get_mempool + + try: + mempool = get_mempool() + chain_id = get_chain_id(tx_data.get("chain_id")) + + # Normalize transaction data + tx_data_dict = normalize_transaction_data(tx_data, chain_id) + _validate_transaction_admission(tx_data_dict, mempool) + + tx_hash = mempool.add(tx_data_dict, chain_id=chain_id) + + return { + "success": True, + "transaction_hash": tx_hash, + "message": "Marketplace transaction submitted" + } + except Exception as e: + _logger.error(f"Failed to submit marketplace transaction", extra={"error": str(e)}) + raise HTTPException(status_code=500, detail=f"Failed to submit marketplace transaction: {str(e)}") + + +@rate_limit(rate=200, per=60) +async def query_transactions( + request: Request, + transaction_type: Optional[str] = None, + island_id: Optional[str] = None, + pair: Optional[str] = None, + status: Optional[str] = None, + order_id: Optional[str] = None, + limit: Optional[int] = 100, + chain_id: str = None +) -> List[Dict[str, Any]]: + """Query transactions with optional filters""" + chain_id = get_chain_id(chain_id) + + with session_scope() as session: + query = select(Transaction).where(Transaction.chain_id == chain_id) + + # Apply filters based on payload fields + transactions = session.exec(query).all() + + results = [] + for tx in transactions: + # Filter by transaction type in payload + if transaction_type and tx.payload.get('type') != transaction_type: + continue + + # Filter by island_id in payload + if island_id and tx.payload.get('island_id') != island_id: + continue + + # Filter by pair in payload + if pair and tx.payload.get('pair') != pair: + continue + + # Filter by status in payload + if status and tx.payload.get('status') != status: + continue + + # Filter by order_id in payload + if order_id and tx.payload.get('order_id') != order_id and tx.payload.get('offer_id') != order_id and tx.payload.get('bid_id') != order_id: + continue + + results.append({ + "transaction_id": tx.id, + "tx_hash": tx.tx_hash, + "sender": tx.sender, + "recipient": tx.recipient, + "payload": tx.payload, + "status": tx.status, + "created_at": tx.created_at.isoformat(), + "timestamp": tx.timestamp, + "nonce": tx.nonce, + "value": tx.value, + "fee": tx.fee + }) + + # Apply limit + if limit: + results = results[:limit] + + return results diff --git a/apps/blockchain-node/src/aitbc_chain/rpc/utils.py b/apps/blockchain-node/src/aitbc_chain/rpc/utils.py new file mode 100644 index 00000000..d5c0387d --- /dev/null +++ b/apps/blockchain-node/src/aitbc_chain/rpc/utils.py @@ -0,0 +1,120 @@ +""" +Utility functions for blockchain RPC endpoints. +""" + +from typing import Any, Dict, List + +from fastapi import HTTPException + +from ..config import settings + +_poa_proposers: Dict[str, Any] = {} + + +def set_poa_proposer(proposer, chain_id: str = None): + """Set the global PoA proposer instance""" + if chain_id is None: + chain_id = getattr(getattr(proposer, "_config", None), "chain_id", None) or get_chain_id(None) + _poa_proposers[chain_id] = proposer + + +def get_poa_proposer(chain_id: str = None): + """Get the global PoA proposer instance""" + chain_id = get_chain_id(chain_id) + return _poa_proposers.get(chain_id) + + +def get_chain_id(chain_id: str = None) -> str: + """Get chain_id from parameter or use default from settings""" + if chain_id is None: + return settings.chain_id or "ait-mainnet" + return chain_id + + +def validate_chain_id(chain_id: str) -> bool: + """Validate that chain_id is in supported_chains list""" + supported_chains = [c.strip() for c in settings.supported_chains.split(",")] + return chain_id in supported_chains + + +def get_supported_chains() -> List[str]: + """Get list of supported chain IDs""" + chains = [chain.strip() for chain in settings.supported_chains.split(",") if chain.strip()] + if not chains and settings.chain_id: + return [settings.chain_id] + return chains + + +def get_chain_db(chain_id: str = None): + """Get chain-specific database engine""" + from ..database import get_engine + + resolved_chain_id = get_chain_id(chain_id) + if not validate_chain_id(resolved_chain_id): + raise HTTPException(status_code=400, detail=f"Chain {resolved_chain_id} not in supported_chains") + return get_engine(resolved_chain_id) + + +def normalize_transaction_data(tx_data: Dict[str, Any], chain_id: str) -> Dict[str, Any]: + """Normalize and validate transaction data""" + sender = tx_data.get("from") + recipient = tx_data.get("to") + if not isinstance(sender, str) or not sender.strip(): + raise ValueError("transaction.from is required") + if not isinstance(recipient, str) or not recipient.strip(): + raise ValueError("transaction.to is required") + + try: + amount = int(tx_data["amount"]) + except KeyError as exc: + raise ValueError("transaction.amount is required") from exc + except (TypeError, ValueError) as exc: + raise ValueError("transaction.amount must be an integer") from exc + + try: + fee = int(tx_data.get("fee", 10)) + except (TypeError, ValueError) as exc: + raise ValueError("transaction.fee must be an integer") from exc + + try: + nonce = int(tx_data.get("nonce", 0)) + except (TypeError, ValueError) as exc: + raise ValueError("transaction.nonce must be an integer") from exc + + if amount < 0: + raise ValueError("transaction.amount must be non-negative") + if fee < 0: + raise ValueError("transaction.fee must be non-negative") + if nonce < 0: + raise ValueError("transaction.nonce must be non-negative") + + payload = tx_data.get("payload", {}) + if payload is None: + payload = {} + + tx_type = tx_data.get("type", "TRANSFER") + if tx_type: + tx_type = tx_type.upper() + + # Ensure payload is a dict + if isinstance(payload, str): + try: + import json + payload = json.loads(payload) + except Exception: + payload = {} + + if not isinstance(payload, dict): + payload = {} + + return { + "chain_id": chain_id, + "type": tx_type, + "from": sender.strip(), + "to": recipient.strip(), + "amount": amount, + "value": amount, # Add value field for state transition compatibility + "fee": fee, + "nonce": nonce, + "payload": payload, + } diff --git a/apps/coordinator-api/src/app/adapters/agent_core_adapters.py b/apps/coordinator-api/src/app/adapters/agent_core_adapters.py new file mode 100644 index 00000000..c0b67481 --- /dev/null +++ b/apps/coordinator-api/src/app/adapters/agent_core_adapters.py @@ -0,0 +1,197 @@ +""" +Adapters for coordinator-api app to implement aitbc-agent-core protocols. +These adapters wrap coordinator-api's native domain models and services. +""" + +from typing import Any +from sqlmodel import Session + +# Import from coordinator-api's own domain models +from app.domain.agent import ( + AgentExecution, + AgentStepExecution, + VerificationLevel, + AgentStatus, + StepType, +) + +# Import from coordinator-api services +from app.services.agent_coordination.security import ( + AgentSecurityManager, + AgentAuditor, + AuditEventType, + SecurityLevel, +) +from app.services.agent_coordination.agent_service import AIAgentOrchestrator + +from aitbc_agent_core.protocols.domain import ( + IAgentExecution, + IAgentStepExecution, + AgentStatus as ProtocolAgentStatus, + VerificationLevel as ProtocolVerificationLevel, + StepType as ProtocolStepType, +) +from aitbc_agent_core.protocols.security import ISecurityManager, IAuditor +from aitbc_agent_core.protocols.orchestrator import IAgentOrchestrator +from aitbc_agent_core.protocols.zk_proof import IZKProofService +from aitbc_agent_core.protocols.database import ISessionProvider + + +class AgentExecutionAdapter(IAgentExecution): + """Adapter for AgentExecution domain model""" + + def __init__(self, execution: AgentExecution): + self._execution = execution + + @property + def id(self) -> str: + return self._execution.id + + @property + def workflow_id(self) -> str: + return self._execution.workflow_id + + @property + def status(self) -> ProtocolAgentStatus: + return ProtocolAgentStatus(self._execution.status) + + @property + def verification_level(self) -> ProtocolVerificationLevel: + return ProtocolVerificationLevel(self._execution.verification_level) + + def to_dict(self) -> dict[str, Any]: + return self._execution.model_dump() + + +class AgentStepExecutionAdapter(IAgentStepExecution): + """Adapter for AgentStepExecution domain model""" + + def __init__(self, step_execution: AgentStepExecution): + self._step_execution = step_execution + + @property + def id(self) -> str: + return self._step_execution.id + + @property + def execution_id(self) -> str: + return self._step_execution.execution_id + + @property + def step_type(self) -> ProtocolStepType: + return ProtocolStepType(self._step_execution.step_type) + + def to_dict(self) -> dict[str, Any]: + return self._step_execution.model_dump() + + +class AgentSecurityManagerAdapter(ISecurityManager): + """Adapter for AgentSecurityManager""" + + def __init__(self, manager: AgentSecurityManager): + self._manager = manager + + async def validate_operation(self, operation: str, context: dict[str, Any]) -> bool: + # Delegate to app-specific implementation + try: + if hasattr(self._manager, 'validate_operation'): + return await self._manager.validate_operation(operation, context) + # Fallback: basic validation + return True + except Exception: + # Fail closed on errors + return False + + async def audit_event(self, event_type: str, details: dict[str, Any]) -> None: + # Delegate to app-specific implementation + if hasattr(self._manager, 'audit_event'): + await self._manager.audit_event(event_type, details) + + +class AgentAuditorAdapter(IAuditor): + """Adapter for AgentAuditor""" + + def __init__(self, auditor: AgentAuditor): + self._auditor = auditor + + async def log_audit(self, event_type: str, details: dict[str, Any]) -> None: + # Delegate to app-specific implementation + if hasattr(self._auditor, 'log_audit'): + await self._auditor.log_audit(event_type, details) + elif hasattr(self._auditor, 'audit_event'): + await self._auditor.audit_event(event_type, details) + + +class AgentOrchestratorAdapter(IAgentOrchestrator): + """Adapter for AIAgentOrchestrator""" + + def __init__(self, orchestrator: AIAgentOrchestrator): + self._orchestrator = orchestrator + + async def execute_workflow( + self, + workflow_id: str, + inputs: dict[str, Any] + ) -> dict[str, Any]: + # Delegate to app-specific implementation + if hasattr(self._orchestrator, 'execute_workflow'): + return await self._orchestrator.execute_workflow(workflow_id, inputs) + # Fallback: return mock result + return { + "execution_id": f"exec_{workflow_id}", + "status": "completed", + "result": inputs, + } + + async def get_status(self, execution_id: str) -> dict[str, Any]: + # Delegate to app-specific implementation + if hasattr(self._orchestrator, 'get_status'): + return await self._orchestrator.get_status(execution_id) + # Fallback: return mock status + return { + "execution_id": execution_id, + "status": "completed", + } + + +class ZKProofServiceAdapter(IZKProofService): + """Adapter for ZK proof service (mock implementation)""" + + def __init__(self, session: Session): + self._session = session + + async def generate_zk_proof( + self, + circuit_name: str, + inputs: dict[str, Any] + ) -> dict[str, Any]: + """Mock ZK proof generation""" + from uuid import uuid4 + return { + "proof_id": f"proof_{uuid4().hex[:8]}", + "circuit_name": circuit_name, + "inputs": inputs, + "proof_size": 1024, + "generation_time": 0.1, + } + + async def verify_proof(self, proof_id: str) -> dict[str, Any]: + """Mock ZK proof verification""" + return { + "verified": True, + "verification_time": 0.05, + "details": {"mock": True} + } + + +class SessionProviderAdapter(ISessionProvider): + """Adapter for SQLModel session management""" + + def __init__(self, session_factory): + self._session_factory = session_factory + + def get_session(self) -> Session: + return self._session_factory() + + def close_session(self, session: Session) -> None: + session.close() diff --git a/apps/coordinator-api/src/app/services/agent_coordination/integration.py b/apps/coordinator-api/src/app/services/agent_coordination/integration.py index ba743a62..a736516f 100755 --- a/apps/coordinator-api/src/app/services/agent_coordination/integration.py +++ b/apps/coordinator-api/src/app/services/agent_coordination/integration.py @@ -1,6 +1,10 @@ """ Agent Integration and Deployment Framework for Verifiable AI Agent Orchestration Integrates agent orchestration with existing ML ZK proof system and provides deployment tools + +MIGRATION IN PROGRESS: This file is being migrated to use shared AgentIntegrationService +from aitbc-agent-core package. See agent_integration_factory.py for the factory pattern. +After migration is complete, duplicated code will be removed. """ import asyncio @@ -23,6 +27,9 @@ from ...domain.agent import AgentExecution, AgentStepExecution, VerificationLeve from .security import AgentAuditor, AgentSecurityManager, AuditEventType, SecurityLevel from .agent_service import AIAgentOrchestrator +# Import shared service factory for gradual migration +from ..agent_integration_factory import get_shared_agent_integration_service + # Mock ZKProofService for testing class ZKProofService: diff --git a/apps/coordinator-api/src/app/services/agent_integration_factory.py b/apps/coordinator-api/src/app/services/agent_integration_factory.py new file mode 100644 index 00000000..0b9bd598 --- /dev/null +++ b/apps/coordinator-api/src/app/services/agent_integration_factory.py @@ -0,0 +1,57 @@ +""" +Factory for creating shared AgentIntegrationService with app-specific adapters. +This enables gradual migration from duplicated code to shared implementation. +""" + +from sqlmodel import Session + +from aitbc_agent_core import AgentIntegrationService +from .adapters.agent_core_adapters import ( + AgentSecurityManagerAdapter, + AgentAuditorAdapter, + AgentOrchestratorAdapter, + ZKProofServiceAdapter, + SessionProviderAdapter, +) +from .agent_coordination.security import AgentSecurityManager, AgentAuditor +from .agent_coordination.agent_service import AIAgentOrchestrator +from ..database import get_session + + +def create_agent_integration_service() -> AgentIntegrationService: + """ + Factory to create shared AgentIntegrationService with app-specific adapters. + + Returns: + Configured AgentIntegrationService instance + """ + # Create app-specific service instances + security_manager = AgentSecurityManager() + auditor = AgentAuditor() + orchestrator = AIAgentOrchestrator() + + # Wrap with protocol adapters + return AgentIntegrationService( + session_provider=SessionProviderAdapter(get_session), + security_manager=AgentSecurityManagerAdapter(security_manager), + auditor=AgentAuditorAdapter(auditor), + orchestrator=AgentOrchestratorAdapter(orchestrator), + zk_proof_service=ZKProofServiceAdapter(get_session()), + ) + + +# Singleton instance for app-wide use +_shared_service: AgentIntegrationService | None = None + + +def get_shared_agent_integration_service() -> AgentIntegrationService: + """ + Get or create the shared AgentIntegrationService singleton. + + Returns: + Shared AgentIntegrationService instance + """ + global _shared_service + if _shared_service is None: + _shared_service = create_agent_integration_service() + return _shared_service diff --git a/apps/exchange/index_fixed.html b/apps/exchange/index_fixed.html deleted file mode 100644 index 7bd08fac..00000000 --- a/apps/exchange/index_fixed.html +++ /dev/null @@ -1,398 +0,0 @@ - - -
- - -