From 7ced360c1f4a37d269b6cfaa45c326b4d27445c7 Mon Sep 17 00:00:00 2001 From: aitbc Date: Tue, 26 May 2026 12:17:58 +0200 Subject: [PATCH] refactor: move cli/core/ to cli/aitbc_cli/core/ for proper package structure - Moved core/ directory to aitbc_cli/core/ to make it a proper subpackage - Updated aitbc_cli.py to load from new path - Simplified aitbc_cli/__init__.py to use normal import instead of spec_from_file_location - Updated all core imports to use aitbc_cli.core prefix - Copied utils files (wallet_daemon_client, error_handling, crypto_utils, subprocess) to aitbc_cli/utils/ - Fixed wallet list command to work with new structure - This fixes ModuleNotFoundError for aitbc_cli.core submodules --- cli/aitbc_cli.py | 2 +- cli/aitbc_cli/__init__.py | 31 +- cli/aitbc_cli/commands/node.py | 4 +- cli/{ => aitbc_cli}/core/__init__.py | 0 cli/{ => aitbc_cli}/core/__version__.py | 0 .../core/agent_communication.py | 5 +- cli/{ => aitbc_cli}/core/analytics.py | 6 +- cli/{ => aitbc_cli}/core/chain_manager.py | 0 cli/{ => aitbc_cli}/core/config.py | 0 cli/{ => aitbc_cli}/core/genesis_generator.py | 4 +- cli/{ => aitbc_cli}/core/imports.py | 0 cli/{ => aitbc_cli}/core/main.py | 2 +- cli/{ => aitbc_cli}/core/marketplace.py | 5 +- cli/{ => aitbc_cli}/core/node_client.py | 4 +- cli/{ => aitbc_cli}/core/plugins.py | 0 cli/aitbc_cli/utils/crypto_utils.py | 237 ++++++++ .../utils/dual_mode_wallet_adapter.py | 4 +- cli/aitbc_cli/utils/error_handling.py | 181 ++++++ cli/aitbc_cli/utils/subprocess.py | 30 + cli/aitbc_cli/utils/wallet_daemon_client.py | 559 ++++++++++++++++++ cli/utils/wallet_daemon_client.py | 2 +- 21 files changed, 1031 insertions(+), 45 deletions(-) rename cli/{ => aitbc_cli}/core/__init__.py (100%) rename cli/{ => aitbc_cli}/core/__version__.py (100%) rename cli/{ => aitbc_cli}/core/agent_communication.py (99%) rename cli/{ => aitbc_cli}/core/analytics.py (99%) rename cli/{ => aitbc_cli}/core/chain_manager.py (100%) rename cli/{ => aitbc_cli}/core/config.py (100%) rename cli/{ => aitbc_cli}/core/genesis_generator.py (99%) rename cli/{ => aitbc_cli}/core/imports.py (100%) rename cli/{ => aitbc_cli}/core/main.py (99%) rename cli/{ => aitbc_cli}/core/marketplace.py (99%) rename cli/{ => aitbc_cli}/core/node_client.py (99%) rename cli/{ => aitbc_cli}/core/plugins.py (100%) create mode 100755 cli/aitbc_cli/utils/crypto_utils.py create mode 100644 cli/aitbc_cli/utils/error_handling.py create mode 100644 cli/aitbc_cli/utils/subprocess.py create mode 100755 cli/aitbc_cli/utils/wallet_daemon_client.py diff --git a/cli/aitbc_cli.py b/cli/aitbc_cli.py index 8cac413a..5263dd9e 100755 --- a/cli/aitbc_cli.py +++ b/cli/aitbc_cli.py @@ -29,7 +29,7 @@ def _load_cli_module() -> ModuleType: if _CLI_MODULE is not None: return _CLI_MODULE - cli_path = Path(__file__).with_name("core") / "main.py" + cli_path = Path(__file__).parent / "aitbc_cli" / "core" / "main.py" spec = importlib.util.spec_from_file_location("aitbc_cli_core_main", cli_path) if spec is None or spec.loader is None: raise ImportError(f"Unable to load modular CLI entrypoint from {cli_path}") diff --git a/cli/aitbc_cli/__init__.py b/cli/aitbc_cli/__init__.py index 6a630956..3b6be07b 100644 --- a/cli/aitbc_cli/__init__.py +++ b/cli/aitbc_cli/__init__.py @@ -7,7 +7,7 @@ import sys __version__ = "0.1.0" __author__ = "AITBC Team" -__email__ = "team@aitbc.net" +__email__ = "andreas.fleckl@bubuit.net" # Provide compatibility aliases for source-tree imports used by modular commands. # Note: core and models are sibling directories, not subpackages of aitbc_cli @@ -26,32 +26,9 @@ def __getattr__(name: str): if name not in __all__: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - # Set up sys.modules for proper package resolution - # core/ is a sibling of aitbc_cli/, but imports reference it as aitbc_cli.core - # We need to register these in sys.modules before loading core/main.py - import importlib.util - from pathlib import Path + # core/ is now a proper subpackage of aitbc_cli/, so we can import normally + from aitbc_cli.core.main import main, cli - cli_dir = Path(__file__).parent - core_dir = cli_dir.parent / "core" - - # Register aitbc_cli.core as a module pointing to the core/ directory - if "aitbc_cli.core" not in sys.modules: - core_spec = importlib.util.spec_from_file_location("aitbc_cli.core", core_dir / "__init__.py") - if core_spec and core_spec.loader: - core_module = importlib.util.module_from_spec(core_spec) - sys.modules["aitbc_cli.core"] = core_module - - # Load core/main.py and register it as aitbc_cli.core.main - cli_path = core_dir / "main.py" - spec = importlib.util.spec_from_file_location("aitbc_cli.core.main", cli_path) - if spec is None or spec.loader is None: - raise ImportError(f"Unable to load modular CLI entrypoint from {cli_path}") - - module = importlib.util.module_from_spec(spec) - sys.modules["aitbc_cli.core.main"] = module - spec.loader.exec_module(module) - - value = module.cli if name == "cli" else module.main + value = cli if name == "cli" else main globals()[name] = value return value diff --git a/cli/aitbc_cli/commands/node.py b/cli/aitbc_cli/commands/node.py index 96ed726b..15c4d06a 100755 --- a/cli/aitbc_cli/commands/node.py +++ b/cli/aitbc_cli/commands/node.py @@ -19,8 +19,8 @@ try: from ..core.node_client import NodeClient except ImportError: from utils import output, error, success, warning - from core.config import MultiChainConfig, load_multichain_config, get_default_node_config, add_node_config, remove_node_config - from core.node_client import NodeClient + from aitbc_cli.core.config import MultiChainConfig, load_multichain_config, get_default_node_config, add_node_config, remove_node_config + from aitbc_cli.core.node_client import NodeClient def info(message): click.echo(message) diff --git a/cli/core/__init__.py b/cli/aitbc_cli/core/__init__.py similarity index 100% rename from cli/core/__init__.py rename to cli/aitbc_cli/core/__init__.py diff --git a/cli/core/__version__.py b/cli/aitbc_cli/core/__version__.py similarity index 100% rename from cli/core/__version__.py rename to cli/aitbc_cli/core/__version__.py diff --git a/cli/core/agent_communication.py b/cli/aitbc_cli/core/agent_communication.py similarity index 99% rename from cli/core/agent_communication.py rename to cli/aitbc_cli/core/agent_communication.py index aa2ea96b..0987c748 100755 --- a/cli/core/agent_communication.py +++ b/cli/aitbc_cli/core/agent_communication.py @@ -13,8 +13,9 @@ from enum import Enum import uuid from collections import defaultdict -from core.config import MultiChainConfig -from core.node_client import NodeClient +from aitbc_cli.core.config import MultiChainConfig +from aitbc_cli.core.node_client import NodeClient +from aitbc_cli.models.chain import ChainInfo, ChainType, ChainStatus import logging logger = logging.getLogger(__name__) diff --git a/cli/core/analytics.py b/cli/aitbc_cli/core/analytics.py similarity index 99% rename from cli/core/analytics.py rename to cli/aitbc_cli/core/analytics.py index cb0aaafd..12d70430 100755 --- a/cli/core/analytics.py +++ b/cli/aitbc_cli/core/analytics.py @@ -11,9 +11,9 @@ from dataclasses import dataclass, asdict from collections import defaultdict, deque import statistics -from core.config import MultiChainConfig -from core.node_client import NodeClient -from models.chain import ChainInfo, ChainType, ChainStatus +from aitbc_cli.core.config import MultiChainConfig +from aitbc_cli.core.node_client import NodeClient +from aitbc_cli.models.chain import ChainInfo, ChainType, ChainStatus import logging logger = logging.getLogger(__name__) diff --git a/cli/core/chain_manager.py b/cli/aitbc_cli/core/chain_manager.py similarity index 100% rename from cli/core/chain_manager.py rename to cli/aitbc_cli/core/chain_manager.py diff --git a/cli/core/config.py b/cli/aitbc_cli/core/config.py similarity index 100% rename from cli/core/config.py rename to cli/aitbc_cli/core/config.py diff --git a/cli/core/genesis_generator.py b/cli/aitbc_cli/core/genesis_generator.py similarity index 99% rename from cli/core/genesis_generator.py rename to cli/aitbc_cli/core/genesis_generator.py index 22e2f031..5811fef7 100755 --- a/cli/core/genesis_generator.py +++ b/cli/aitbc_cli/core/genesis_generator.py @@ -8,8 +8,8 @@ import yaml from datetime import datetime from pathlib import Path from typing import Dict, Any, Optional -from core.config import MultiChainConfig -from models.chain import GenesisBlock, GenesisConfig, ChainType, ConsensusAlgorithm +from aitbc_cli.core.config import MultiChainConfig +from aitbc_cli.models.chain import GenesisBlock, GenesisConfig, ChainType, ConsensusAlgorithm class GenesisValidationError(Exception): """Genesis validation error""" diff --git a/cli/core/imports.py b/cli/aitbc_cli/core/imports.py similarity index 100% rename from cli/core/imports.py rename to cli/aitbc_cli/core/imports.py diff --git a/cli/core/main.py b/cli/aitbc_cli/core/main.py similarity index 99% rename from cli/core/main.py rename to cli/aitbc_cli/core/main.py index b10836bd..1ebfc4b3 100644 --- a/cli/core/main.py +++ b/cli/aitbc_cli/core/main.py @@ -7,7 +7,7 @@ import sys from pathlib import Path # Ensure parent directory is on path for aitbc_cli imports -CLI_DIR = Path(__file__).parent.parent +CLI_DIR = Path(__file__).parent.parent.parent if str(CLI_DIR) not in sys.path: sys.path.insert(0, str(CLI_DIR)) diff --git a/cli/core/marketplace.py b/cli/aitbc_cli/core/marketplace.py similarity index 99% rename from cli/core/marketplace.py rename to cli/aitbc_cli/core/marketplace.py index dd205de5..3e1238ae 100755 --- a/cli/core/marketplace.py +++ b/cli/aitbc_cli/core/marketplace.py @@ -14,8 +14,9 @@ import uuid from decimal import Decimal from collections import defaultdict -from core.config import MultiChainConfig -from core.node_client import NodeClient +from aitbc_cli.core.config import MultiChainConfig +from aitbc_cli.core.node_client import NodeClient +from aitbc_cli.models.chain import ChainInfo, ChainType, ChainStatus import logging logger = logging.getLogger(__name__) diff --git a/cli/core/node_client.py b/cli/aitbc_cli/core/node_client.py similarity index 99% rename from cli/core/node_client.py rename to cli/aitbc_cli/core/node_client.py index fa1e8879..060642b8 100755 --- a/cli/core/node_client.py +++ b/cli/aitbc_cli/core/node_client.py @@ -8,8 +8,8 @@ import json import os import logging from typing import Dict, List, Optional, Any -from core.config import NodeConfig -from models.chain import ChainInfo, ChainType, ChainStatus, ConsensusAlgorithm +from aitbc_cli.core.config import NodeConfig +from aitbc_cli.models.chain import ChainInfo, ChainType, ChainStatus, ConsensusAlgorithm logger = logging.getLogger(__name__) diff --git a/cli/core/plugins.py b/cli/aitbc_cli/core/plugins.py similarity index 100% rename from cli/core/plugins.py rename to cli/aitbc_cli/core/plugins.py diff --git a/cli/aitbc_cli/utils/crypto_utils.py b/cli/aitbc_cli/utils/crypto_utils.py new file mode 100755 index 00000000..a8833f8c --- /dev/null +++ b/cli/aitbc_cli/utils/crypto_utils.py @@ -0,0 +1,237 @@ +""" +Cryptographic Utilities for CLI Security +Provides real signature verification for multisig operations +""" + +import hashlib +import secrets +from typing import Dict, Optional, Tuple +from eth_account import Account +from eth_utils import to_checksum_address, keccak +import json + + +def create_signature_challenge(tx_data: Dict, nonce: str) -> str: + """ + Create a cryptographic challenge for transaction signing + + Args: + tx_data: Transaction data to sign + nonce: Unique nonce to prevent replay attacks + + Returns: + Challenge string to be signed + """ + # Create deterministic challenge from transaction data + challenge_data = { + "tx_id": tx_data.get("tx_id"), + "to": tx_data.get("to"), + "amount": tx_data.get("amount"), + "nonce": nonce, + "timestamp": tx_data.get("timestamp") + } + + # Sort keys for deterministic ordering + challenge_str = json.dumps(challenge_data, sort_keys=True, separators=(',', ':')) + challenge_hash = keccak(challenge_str.encode()) + + return f"AITBC_MULTISIG_CHALLENGE:{challenge_hash.hex()}" + + +def verify_signature( + challenge: str, + signature: str, + signer_address: str +) -> bool: + """ + Verify that a signature was created by the specified signer + + Args: + challenge: Challenge string that was signed + signature: Hex signature string + signer_address: Expected signer address + + Returns: + True if signature is valid + """ + try: + # Remove 0x prefix if present + if signature.startswith("0x"): + signature = signature[2:] + + # Convert to bytes + signature_bytes = bytes.fromhex(signature) + + # Recover address from signature + message_hash = keccak(challenge.encode()) + recovered_address = Account.recover_message( + signable_hash=message_hash, + signature=signature_bytes + ) + + # Compare with expected signer + return to_checksum_address(recovered_address) == to_checksum_address(signer_address) + + except Exception: + return False + + +def sign_challenge(challenge: str, private_key: str) -> str: + """ + Sign a challenge with a private key + + Args: + challenge: Challenge string to sign + private_key: Private key in hex format + + Returns: + Signature as hex string + """ + try: + # Remove 0x prefix if present + if private_key.startswith("0x"): + private_key = private_key[2:] + + account = Account.from_key("0x" + private_key) + message_hash = keccak(challenge.encode()) + signature = account.sign_message(message_hash) + + return "0x" + signature.signature.hex() + + except Exception as e: + raise ValueError(f"Failed to sign challenge: {e}") + + +def generate_nonce() -> str: + """Generate a secure nonce for transaction challenges""" + return secrets.token_hex(16) + + +def validate_multisig_transaction(tx_data: Dict) -> Tuple[bool, str]: + """ + Validate multisig transaction structure + + Args: + tx_data: Transaction data to validate + + Returns: + Tuple of (is_valid, error_message) + """ + required_fields = ["tx_id", "to", "amount", "timestamp", "nonce"] + + for field in required_fields: + if field not in tx_data: + return False, f"Missing required field: {field}" + + # Validate address format (AITBC addresses start with 'ait') + to_address = tx_data["to"] + if not to_address.startswith("ait"): + return False, "Invalid recipient address format: must start with 'ait'" + if len(to_address) < 50 or len(to_address) > 70: + return False, "Invalid recipient address format: invalid length" + # Check that the rest is hex-like (after 'ait' prefix) + if not all(c.lower() in '0123456789abcdef' for c in to_address[3:]): + return False, "Invalid recipient address format: invalid characters" + + # Validate amount + try: + amount = float(tx_data["amount"]) + if amount <= 0: + return False, "Amount must be positive" + except Exception: + return False, "Invalid amount format" + + return True, "" + + +class MultisigSecurityManager: + """Security manager for multisig operations""" + + def __init__(self): + self.pending_challenges: Dict[str, Dict] = {} + + def create_signing_request( + self, + tx_data: Dict, + multisig_wallet: str + ) -> Dict[str, str]: + """ + Create a signing request with cryptographic challenge + + Args: + tx_data: Transaction data + multisig_wallet: Multisig wallet identifier + + Returns: + Signing request with challenge + """ + # Validate transaction + is_valid, error = validate_multisig_transaction(tx_data) + if not is_valid: + raise ValueError(f"Invalid transaction: {error}") + + # Generate nonce and challenge + nonce = generate_nonce() + challenge = create_signature_challenge(tx_data, nonce) + + # Store challenge for verification + self.pending_challenges[tx_data["tx_id"]] = { + "challenge": challenge, + "tx_data": tx_data, + "multisig_wallet": multisig_wallet, + "nonce": nonce, + "created_at": secrets.token_hex(8) + } + + return { + "tx_id": tx_data["tx_id"], + "challenge": challenge, + "nonce": nonce, + "signers_required": len(tx_data.get("required_signers", [])), + "message": f"Please sign this challenge to authorize transaction {tx_data['tx_id']}" + } + + def verify_and_add_signature( + self, + tx_id: str, + signature: str, + signer_address: str + ) -> Tuple[bool, str]: + """ + Verify signature and add to transaction + + Args: + tx_id: Transaction ID + signature: Signature to verify + signer_address: Address of signer + + Returns: + Tuple of (success, message) + """ + if tx_id not in self.pending_challenges: + return False, "Transaction not found or expired" + + challenge_data = self.pending_challenges[tx_id] + challenge = challenge_data["challenge"] + + # Verify signature + if not verify_signature(challenge, signature, signer_address): + return False, f"Invalid signature for signer {signer_address}" + + # Check if signer is authorized + tx_data = challenge_data["tx_data"] + authorized_signers = tx_data.get("required_signers", []) + + if signer_address not in authorized_signers: + return False, f"Signer {signer_address} is not authorized" + + return True, "Signature verified successfully" + + def cleanup_challenge(self, tx_id: str): + """Clean up challenge after transaction completion""" + if tx_id in self.pending_challenges: + del self.pending_challenges[tx_id] + + +# Global security manager instance +multisig_security = MultisigSecurityManager() diff --git a/cli/aitbc_cli/utils/dual_mode_wallet_adapter.py b/cli/aitbc_cli/utils/dual_mode_wallet_adapter.py index 37ab8833..d21c0629 100755 --- a/cli/aitbc_cli/utils/dual_mode_wallet_adapter.py +++ b/cli/aitbc_cli/utils/dual_mode_wallet_adapter.py @@ -12,9 +12,9 @@ from typing import Dict, Any, Optional, List, Union from datetime import datetime sys.path.insert(0, "/opt/aitbc/cli") -from utils.wallet_daemon_client import WalletDaemonClient, WalletInfo, WalletBalance, ChainInfo, WalletMigrationResult +from aitbc_cli.utils.wallet_daemon_client import WalletDaemonClient, WalletInfo, WalletBalance, ChainInfo, WalletMigrationResult from aitbc_cli.config import CLIConfig as Config -from utils import error, success, output +from aitbc_cli.utils import error, success, output class DualModeWalletAdapter: diff --git a/cli/aitbc_cli/utils/error_handling.py b/cli/aitbc_cli/utils/error_handling.py new file mode 100644 index 00000000..a1e1162b --- /dev/null +++ b/cli/aitbc_cli/utils/error_handling.py @@ -0,0 +1,181 @@ +""" +Common error handling utilities for AITBC CLI +Provides standardized error handling patterns and utilities for CLI commands +""" + +import sys +from typing import Optional, Callable, Any +from functools import wraps +from . import error, warning, info + + +class CLIError(Exception): + """Base exception for CLI errors""" + def __init__(self, message: str, exit_code: int = 1): + self.message = message + self.exit_code = exit_code + super().__init__(self.message) + + +class NetworkError(CLIError): + """Network-related errors""" + def __init__(self, message: str): + super().__init__(f"Network error: {message}", exit_code=2) + + +class ConfigurationError(CLIError): + """Configuration-related errors""" + def __init__(self, message: str): + super().__init__(f"Configuration error: {message}", exit_code=3) + + +class ValidationError(CLIError): + """Validation errors for user input""" + def __init__(self, message: str): + super().__init__(f"Validation error: {message}", exit_code=4) + + +class APIError(CLIError): + """API-related errors""" + def __init__(self, message: str, status_code: Optional[int] = None): + msg = f"API error: {message}" + if status_code: + msg += f" (HTTP {status_code})" + super().__init__(msg, exit_code=5) + + +def handle_cli_error(func: Callable) -> Callable: + """ + Decorator to standardize error handling in CLI commands. + + Catches common exceptions and displays user-friendly error messages. + + Args: + func: Function to wrap with error handling + + Returns: + Wrapped function with standardized error handling + """ + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except CLIError as e: + error(e.message) + sys.exit(e.exit_code) + except KeyboardInterrupt: + warning("\nOperation cancelled by user") + sys.exit(130) + except Exception as e: + error(f"Unexpected error: {e}") + sys.exit(1) + return wrapper + + +def handle_async_cli_error(func: Callable) -> Callable: + """ + Decorator to standardize error handling in async CLI commands. + + Args: + func: Async function to wrap with error handling + + Returns: + Wrapped async function with standardized error handling + """ + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except CLIError as e: + error(e.message) + sys.exit(e.exit_code) + except KeyboardInterrupt: + warning("\nOperation cancelled by user") + sys.exit(130) + except Exception as e: + error(f"Unexpected error: {e}") + sys.exit(1) + return wrapper + + +def safe_execute( + operation: Callable, + error_message: str = "Operation failed", + default_return: Any = None, + raise_on_error: bool = False +) -> Any: + """ + Safely execute an operation with standardized error handling. + + Args: + operation: Function to execute + error_message: Custom error message prefix + default_return: Value to return on error (if not raising) + raise_on_error: Whether to raise exception on error + + Returns: + Operation result or default_return on error + + Raises: + Exception: If raise_on_error is True and operation fails + """ + try: + return operation() + except Exception as e: + if raise_on_error: + raise + error(f"{error_message}: {e}") + return default_return + + +def validate_required_fields(data: dict, required_fields: list) -> None: + """ + Validate that required fields are present in data dictionary. + + Args: + data: Dictionary to validate + required_fields: List of required field names + + Raises: + ValidationError: If any required field is missing + """ + missing_fields = [field for field in required_fields if field not in data or data[field] is None] + if missing_fields: + raise ValidationError(f"Missing required fields: {', '.join(missing_fields)}") + + +def validate_url(url: str) -> bool: + """ + Validate URL format. + + Args: + url: URL string to validate + + Returns: + True if valid, False otherwise + """ + import re + url_pattern = re.compile( + r'^https?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... + r'localhost|' # localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + return bool(url_pattern.match(url)) + + +def validate_address(address: str) -> bool: + """ + Validate Ethereum address format. + + Args: + address: Ethereum address string + + Returns: + True if valid, False otherwise + """ + import re + # Basic Ethereum address validation (0x followed by 40 hex characters) + address_pattern = re.compile(r'^0x[a-fA-F0-9]{40}$') + return bool(address_pattern.match(address)) diff --git a/cli/aitbc_cli/utils/subprocess.py b/cli/aitbc_cli/utils/subprocess.py new file mode 100644 index 00000000..79de7122 --- /dev/null +++ b/cli/aitbc_cli/utils/subprocess.py @@ -0,0 +1,30 @@ +import subprocess +import sys +from typing import List, Optional, Union, Any +from . import error, output +import logging +logger = logging.getLogger(__name__) + + +def run_subprocess(cmd: List[str], check: bool = True, capture_output: bool = True, shell: bool = False, **kwargs: Any) -> Optional[Union[str, subprocess.CompletedProcess]]: + """Run a subprocess command safely with logging""" + try: + # Always use shell=False for security + result = subprocess.run(cmd, check=check, capture_output=capture_output, text=True, shell=False, **kwargs) + + if capture_output: + return result.stdout.strip() + return result + + except subprocess.CalledProcessError as e: + error(f"Command failed with exit code {e.returncode}") + if capture_output and getattr(e, 'stderr', None): + logger.info(e.stderr, file=sys.stderr) + if check: + sys.exit(e.returncode) + return getattr(e, 'stdout', None) if capture_output else None + except Exception as e: + error(f"Failed to execute command: {e}") + if check: + sys.exit(1) + return None diff --git a/cli/aitbc_cli/utils/wallet_daemon_client.py b/cli/aitbc_cli/utils/wallet_daemon_client.py new file mode 100755 index 00000000..4081a200 --- /dev/null +++ b/cli/aitbc_cli/utils/wallet_daemon_client.py @@ -0,0 +1,559 @@ +"""Wallet Daemon Client for AITBC CLI + +This module provides a client for interacting with the AITBC wallet daemon. +""" + +import sys +import json +import base64 +from typing import TYPE_CHECKING, Dict, Any, Optional, List +from pathlib import Path +from dataclasses import dataclass + +from aitbc import AITBCHTTPClient, NetworkError + +sys.path.insert(0, "/opt/aitbc/cli") +from utils import error, success + +if TYPE_CHECKING: + from aitbc_cli.core.config import Config + + +@dataclass +class ChainInfo: + """Chain information from daemon""" + chain_id: str + name: str + status: str + coordinator_url: str + created_at: str + updated_at: str + wallet_count: int + recent_activity: int + + +@dataclass +class WalletInfo: + """Wallet information from daemon""" + wallet_id: str + chain_id: str + public_key: str + address: Optional[str] = None + created_at: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + + +@dataclass +class WalletBalance: + """Wallet balance information""" + wallet_id: str + chain_id: str + balance: float + address: Optional[str] = None + last_updated: Optional[str] = None + + +@dataclass +class WalletMigrationResult: + """Result of wallet migration between chains""" + success: bool + source_wallet: WalletInfo + target_wallet: WalletInfo + migration_timestamp: str + + +class WalletDaemonClient: + """Client for interacting with AITBC wallet daemon""" + + def __init__(self, config: "Config"): + self.config = config + self.base_url = config.wallet_url.rstrip('/') + self.timeout = getattr(config, 'timeout', 30) + + def _get_http_client(self) -> AITBCHTTPClient: + """Create HTTP client with appropriate settings""" + return AITBCHTTPClient( + base_url=self.base_url, + timeout=self.timeout, + headers={"Content-Type": "application/json"} + ) + + def is_available(self) -> bool: + """Check if wallet daemon is available and responsive""" + try: + client = self._get_http_client() + client.get("/health") + return True + except NetworkError: + return False + except Exception: + return False + + def get_status(self) -> Dict[str, Any]: + """Get wallet daemon status information""" + try: + client = self._get_http_client() + return client.get("/health") + except NetworkError as e: + return {"status": "unavailable", "error": str(e)} + except Exception as e: + return {"status": "error", "error": str(e)} + + def create_wallet(self, wallet_id: str, password: str, metadata: Optional[Dict[str, Any]] = None) -> WalletInfo: + """Create a new wallet in the daemon""" + try: + client = self._get_http_client() + payload = { + "wallet_id": wallet_id, + "password": password, + "metadata": metadata or {} + } + + data = client.post("/v1/wallets", json=payload) + return WalletInfo( + wallet_id=data["wallet_id"], + chain_id=data.get("chain_id", "default"), + public_key=data["public_key"], + address=data.get("address"), + created_at=data.get("created_at"), + metadata=data.get("metadata") + ) + except NetworkError as e: + error(f"Error creating wallet: {e}") + raise + except Exception as e: + error(f"Error creating wallet: {str(e)}") + raise + + def list_wallets(self) -> List[WalletInfo]: + """List all wallets in the daemon""" + try: + client = self._get_http_client() + data = client.get("/v1/wallets") + wallets = [] + # Handle both "wallets" and "items" keys for compatibility + wallet_list = data.get("wallets", data.get("items", [])) + for wallet_data in wallet_list: + wallets.append(WalletInfo( + wallet_id=wallet_data.get("wallet_id", wallet_data.get("wallet_name", "")), + chain_id=wallet_data.get("chain_id", "default"), + public_key=wallet_data.get("public_key", ""), + address=wallet_data.get("address", ""), + created_at=wallet_data.get("created_at", ""), + metadata=wallet_data.get("metadata", {}) + )) + return wallets + except NetworkError as e: + error(f"Failed to list daemon wallets: {str(e)}") + raise + except Exception as e: + error(f"Error listing wallets: {str(e)}") + raise + + def get_wallet_info(self, wallet_id: str) -> Optional[WalletInfo]: + """Get information about a specific wallet""" + try: + client = self._get_http_client() + data = client.get(f"/v1/wallets/{wallet_id}") + return WalletInfo( + wallet_id=data["wallet_id"], + chain_id=data.get("chain_id", "default"), + public_key=data["public_key"], + address=data.get("address"), + created_at=data.get("created_at"), + metadata=data.get("metadata") + ) + except NetworkError as e: + error(f"Failed to get wallet info: {e}") + return None + except Exception as e: + error(f"Error getting wallet info: {str(e)}") + return None + + def get_wallet_balance(self, wallet_id: str) -> Optional[WalletBalance]: + """Get wallet balance from daemon""" + try: + client = self._get_http_client() + data = client.get(f"/v1/wallets/{wallet_id}/balance") + return WalletBalance( + wallet_id=wallet_id, + chain_id=data.get("chain_id", "default"), + balance=data["balance"], + address=data.get("address"), + last_updated=data.get("last_updated") + ) + except NetworkError as e: + error(f"Failed to get wallet balance: {e}") + return None + except Exception as e: + error(f"Error getting wallet balance: {str(e)}") + return None + + def sign_message(self, wallet_id: str, password: str, message: bytes) -> str: + """Sign a message with wallet private key""" + try: + with self._get_http_client() as client: + # Encode message as base64 for transmission + message_b64 = base64.b64encode(message).decode() + + payload = { + "password": password, + "message": message_b64 + } + + response = client.post(f"/v1/wallets/{wallet_id}/sign", json=payload) + if response.status_code == 200: + data = response.json() + return data["signature_base64"] + else: + error(f"Failed to sign message: {response.text}") + raise Exception(f"HTTP {response.status_code}: {response.text}") + except Exception as e: + error(f"Error signing message: {str(e)}") + raise + + def send_transaction(self, wallet_id: str, password: str, to_address: str, amount: float, + description: Optional[str] = None) -> Dict[str, Any]: + """Send a transaction via the daemon""" + try: + with self._get_http_client() as client: + payload = { + "password": password, + "to_address": to_address, + "amount": amount, + "description": description or "" + } + + response = client.post(f"/v1/wallets/{wallet_id}/send", json=payload) + if response.status_code == 201: + return response.json() + else: + error(f"Failed to send transaction: {response.text}") + raise Exception(f"HTTP {response.status_code}: {response.text}") + except Exception as e: + error(f"Error sending transaction: {str(e)}") + raise + + def unlock_wallet(self, wallet_id: str, password: str) -> bool: + """Unlock a wallet for operations""" + try: + with self._get_http_client() as client: + payload = {"password": password} + response = client.post(f"/v1/wallets/{wallet_id}/unlock", json=payload) + return response.status_code == 200 + except Exception: + return False + + def lock_wallet(self, wallet_id: str) -> bool: + """Lock a wallet""" + try: + with self._get_http_client() as client: + response = client.post(f"/v1/wallets/{wallet_id}/lock") + return response.status_code == 200 + except Exception: + return False + + def delete_wallet(self, wallet_id: str, password: str) -> bool: + """Delete a wallet from daemon""" + try: + with self._get_http_client() as client: + payload = {"password": password} + response = client.delete(f"/v1/wallets/{wallet_id}", json=payload) + return response.status_code == 200 + except Exception: + return False + + def jsonrpc_call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Make a JSON-RPC call to the daemon""" + try: + with self._get_http_client() as client: + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params or {}, + "id": 1 + } + + response = client.post("/rpc", json=payload) + if response.status_code == 200: + return response.json() + else: + error(f"JSON-RPC call failed: {response.text}") + raise Exception(f"HTTP {response.status_code}: {response.text}") + except Exception as e: + error(f"Error making JSON-RPC call: {str(e)}") + raise + + # Multi-Chain Methods + + def list_chains(self) -> List[ChainInfo]: + """List all blockchain chains""" + try: + with self._get_http_client() as client: + response = client.get("/v1/chains") + if response.status_code == 200: + data = response.json() + chains = [] + for chain_data in data.get("chains", []): + chains.append(ChainInfo( + chain_id=chain_data["chain_id"], + name=chain_data["name"], + status=chain_data["status"], + coordinator_url=chain_data["coordinator_url"], + created_at=chain_data["created_at"], + updated_at=chain_data["updated_at"], + wallet_count=chain_data["wallet_count"], + recent_activity=chain_data["recent_activity"] + )) + return chains + else: + error(f"Failed to list chains: {response.text}") + raise Exception(f"HTTP {response.status_code}: {response.text}") + except Exception as e: + error(f"Error listing chains: {str(e)}") + raise + + def create_chain(self, chain_id: str, name: str, coordinator_url: str, + coordinator_api_key: str, metadata: Optional[Dict[str, Any]] = None) -> ChainInfo: + """Create a new blockchain chain""" + try: + with self._get_http_client() as client: + payload = { + "chain_id": chain_id, + "name": name, + "coordinator_url": coordinator_url, + "coordinator_api_key": coordinator_api_key, + "metadata": metadata or {} + } + + response = client.post("/v1/chains", json=payload) + if response.status_code == 201: + data = response.json() + chain_data = data["chain"] + return ChainInfo( + chain_id=chain_data["chain_id"], + name=chain_data["name"], + status=chain_data["status"], + coordinator_url=chain_data["coordinator_url"], + created_at=chain_data["created_at"], + updated_at=chain_data["updated_at"], + wallet_count=chain_data["wallet_count"], + recent_activity=chain_data["recent_activity"] + ) + else: + error(f"Failed to create chain: {response.text}") + raise Exception(f"HTTP {response.status_code}: {response.text}") + except Exception as e: + error(f"Error creating chain: {str(e)}") + raise + + def create_wallet(self, wallet_id: str, password: str, metadata: Optional[Dict[str, Any]] = None) -> WalletInfo: + """Create a new wallet in the daemon""" + try: + client = self._get_http_client() + payload = { + "wallet_id": wallet_id, + "password": password, + "metadata": metadata or {} + } + + data = client.post("/v1/wallets", json=payload) + return WalletInfo( + wallet_id=data["wallet_id"], + public_key=data["public_key"], + address=data.get("address"), + created_at=data.get("created_at"), + metadata=data.get("metadata") + ) + except NetworkError as e: + error(f"Failed to create wallet: {e}") + raise + except Exception as e: + error(f"Error creating wallet: {str(e)}") + raise + + def create_wallet_in_chain(self, chain_id: str, wallet_id: str, password: str, + metadata: Optional[Dict[str, Any]] = None) -> WalletInfo: + """Create a wallet in a specific chain""" + try: + with self._get_http_client() as client: + payload = { + "chain_id": chain_id, + "wallet_id": wallet_id, + "password": password, + "metadata": metadata or {} + } + + response = client.post(f"/v1/chains/{chain_id}/wallets", json=payload) + if response.status_code == 201: + data = response.json() + wallet_data = data["wallet"] + return WalletInfo( + wallet_id=wallet_data["wallet_id"], + chain_id=wallet_data["chain_id"], + public_key=wallet_data["public_key"], + address=wallet_data.get("address"), + created_at=wallet_data.get("created_at"), + metadata=wallet_data.get("metadata") + ) + else: + error(f"Failed to create wallet in chain {chain_id}: {response.text}") + raise Exception(f"HTTP {response.status_code}: {response.text}") + except Exception as e: + error(f"Error creating wallet in chain {chain_id}: {str(e)}") + raise + + def list_wallets_in_chain(self, chain_id: str) -> List[WalletInfo]: + """List wallets in a specific chain""" + try: + with self._get_http_client() as client: + response = client.get(f"/v1/chains/{chain_id}/wallets") + if response.status_code == 200: + data = response.json() + wallets = [] + for wallet_data in data.get("items", []): + wallets.append(WalletInfo( + wallet_id=wallet_data["wallet_id"], + chain_id=wallet_data["chain_id"], + public_key=wallet_data["public_key"], + address=wallet_data.get("address"), + created_at=wallet_data.get("created_at"), + metadata=wallet_data.get("metadata") + )) + return wallets + else: + error(f"Failed to list wallets in chain {chain_id}: {response.text}") + raise Exception(f"HTTP {response.status_code}: {response.text}") + except Exception as e: + error(f"Error listing wallets in chain {chain_id}: {str(e)}") + raise + + def get_wallet_info_in_chain(self, chain_id: str, wallet_id: str) -> Optional[WalletInfo]: + """Get wallet information from a specific chain""" + try: + wallets = self.list_wallets_in_chain(chain_id) + for wallet in wallets: + if wallet.wallet_id == wallet_id: + return wallet + return None + except Exception as e: + error(f"Error getting wallet info from chain {chain_id}: {str(e)}") + return None + + def unlock_wallet_in_chain(self, chain_id: str, wallet_id: str, password: str) -> bool: + """Unlock a wallet in a specific chain""" + try: + with self._get_http_client() as client: + payload = {"password": password} + response = client.post(f"/v1/chains/{chain_id}/wallets/{wallet_id}/unlock", json=payload) + return response.status_code == 200 + except Exception: + return False + + def sign_message_in_chain(self, chain_id: str, wallet_id: str, password: str, message: bytes) -> Optional[str]: + """Sign a message with a wallet in a specific chain""" + try: + with self._get_http_client() as client: + payload = { + "password": password, + "message_base64": base64.b64encode(message).decode() + } + + response = client.post(f"/v1/chains/{chain_id}/wallets/{wallet_id}/sign", json=payload) + if response.status_code == 200: + data = response.json() + return data.get("signature_base64") + else: + return None + except Exception: + return None + + def get_wallet_balance_in_chain(self, chain_id: str, wallet_id: str) -> Optional[WalletBalance]: + """Get wallet balance in a specific chain""" + try: + # For now, return a placeholder balance + # In a real implementation, this would call the chain-specific balance endpoint + wallet_info = self.get_wallet_info_in_chain(chain_id, wallet_id) + if wallet_info: + return WalletBalance( + wallet_id=wallet_id, + chain_id=chain_id, + balance=0.0, # Placeholder + address=wallet_info.address + ) + return None + except Exception as e: + error(f"Error getting wallet balance in chain {chain_id}: {str(e)}") + return None + + def migrate_wallet(self, source_chain_id: str, target_chain_id: str, wallet_id: str, + password: str, new_password: Optional[str] = None) -> Optional[WalletMigrationResult]: + """Migrate a wallet from one chain to another""" + try: + with self._get_http_client() as client: + payload = { + "source_chain_id": source_chain_id, + "target_chain_id": target_chain_id, + "wallet_id": wallet_id, + "password": password + } + if new_password: + payload["new_password"] = new_password + + response = client.post("/v1/wallets/migrate", json=payload) + if response.status_code == 200: + data = response.json() + + source_wallet = WalletInfo( + wallet_id=data["source_wallet"]["wallet_id"], + chain_id=data["source_wallet"]["chain_id"], + public_key=data["source_wallet"]["public_key"], + address=data["source_wallet"].get("address"), + metadata=data["source_wallet"].get("metadata") + ) + + target_wallet = WalletInfo( + wallet_id=data["target_wallet"]["wallet_id"], + chain_id=data["target_wallet"]["chain_id"], + public_key=data["target_wallet"]["public_key"], + address=data["target_wallet"].get("address"), + metadata=data["target_wallet"].get("metadata") + ) + + return WalletMigrationResult( + success=data["success"], + source_wallet=source_wallet, + target_wallet=target_wallet, + migration_timestamp=data["migration_timestamp"] + ) + else: + error(f"Failed to migrate wallet: {response.text}") + return None + except Exception as e: + error(f"Error migrating wallet: {str(e)}") + return None + + def get_chain_status(self) -> Dict[str, Any]: + """Get overall chain status and statistics""" + try: + chains = self.list_chains() + active_chains = [c for c in chains if c.status == "active"] + + return { + "total_chains": len(chains), + "active_chains": len(active_chains), + "total_wallets": sum(c.wallet_count for c in chains), + "chains": [ + { + "chain_id": chain.chain_id, + "name": chain.name, + "status": chain.status, + "wallet_count": chain.wallet_count, + "recent_activity": chain.recent_activity + } + for chain in chains + ] + } + except Exception as e: + error(f"Error getting chain status: {str(e)}") + return {"error": str(e)} diff --git a/cli/utils/wallet_daemon_client.py b/cli/utils/wallet_daemon_client.py index e0888211..4081a200 100755 --- a/cli/utils/wallet_daemon_client.py +++ b/cli/utils/wallet_daemon_client.py @@ -16,7 +16,7 @@ sys.path.insert(0, "/opt/aitbc/cli") from utils import error, success if TYPE_CHECKING: - from core.config import Config + from aitbc_cli.core.config import Config @dataclass