diff --git a/Dockerfile.agent b/Dockerfile.agent index 697f845..15d5103 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -9,6 +9,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* COPY pyproject.toml README.md uv.lock* ./ +COPY .git ./.git # Sync dependencies using uv RUN uv sync --frozen --no-install-project || uv sync --no-install-project diff --git a/docker-compose.agent.yml b/docker-compose.agent.yml index 49a0ea7..ac2ecb3 100644 --- a/docker-compose.agent.yml +++ b/docker-compose.agent.yml @@ -1,4 +1,15 @@ services: + redis: + image: redis:7-alpine + command: redis-server --appendonly yes --appendfsync everysec + volumes: + - redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 5 + omniclaw-agent: build: context: . @@ -6,7 +17,7 @@ services: command: uv run omniclaw server --host 0.0.0.0 --port 8080 env_file: .env environment: - - OMNICLAW_REDIS_URL=redis://host.docker.internal:6379/0 + - OMNICLAW_REDIS_URL=redis://redis:6379/0 - OMNICLAW_AGENT_POLICY_PATH=/config/policy.json - OMNICLAW_AGENT_TOKEN=payment-agent-token - OMNICLAW_LOG_LEVEL=INFO @@ -14,5 +25,9 @@ services: - ./examples/agent/policy.json:/config/policy.json:ro ports: - "8080:8080" - extra_hosts: - - "host.docker.internal:host-gateway" + depends_on: + redis: + condition: service_healthy + +volumes: + redis-data: diff --git a/examples/agent/policy.json b/examples/agent/policy.json index a3f501b..241d229 100644 --- a/examples/agent/policy.json +++ b/examples/agent/policy.json @@ -2,9 +2,9 @@ "version": "1.0", "tokens": { "payment-agent-token": { - "wallet_alias": "payment-agent", + "wallet_alias": "omni-bot-v4", "active": true, - "label": "Main Payment Agent" + "label": "Main Omni Bot" }, "api-agent-token": { "wallet_alias": "api-agent", @@ -18,9 +18,9 @@ } }, "wallets": { - "payment-agent": { - "name": "Main Payment Agent", - "description": "Primary agent for general payments", + "omni-bot-v4": { + "name": "Omni Bot V1", + "description": "Upgraded autonomous bot", "limits": { "daily_max": "100.00", "hourly_max": "50.00", diff --git a/src/omniclaw/__init__.py b/src/omniclaw/__init__.py index 2dfb033..65b9d2c 100644 --- a/src/omniclaw/__init__.py +++ b/src/omniclaw/__init__.py @@ -19,6 +19,13 @@ ... ) """ +import warnings + +# Suppress noisy deprecation warnings from downstream dependencies (e.g. web3, circle-sdk) +# We do this at the very top of the package to ensure it catches warnings during imports. +warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") +warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources") + from omniclaw.client import OmniClaw from omniclaw.core.config import Config from omniclaw.core.exceptions import ( @@ -140,7 +147,7 @@ ) from omniclaw.trust.gate import TrustGate -__version__ = "0.0.1" +__version__ = "0.1.0" __all__ = [ # Main Client "OmniClaw", diff --git a/src/omniclaw/agent/auth.py b/src/omniclaw/agent/auth.py index 19ab786..5c38210 100644 --- a/src/omniclaw/agent/auth.py +++ b/src/omniclaw/agent/auth.py @@ -34,15 +34,12 @@ async def authenticate( self, credentials: HTTPAuthorizationCredentials, ) -> AuthenticatedAgent: - """Authenticate request using token.""" + """Authenticate request using token against the policy mapping.""" token = credentials.credentials - if not self._agent_token or token != self._agent_token: - raise HTTPException(status_code=401, detail="Invalid token") - - wallet_id = self._policy.get_wallet_id() + wallet_id = self._policy.get_wallet_id_for_token(token) if not wallet_id: - raise HTTPException(status_code=400, detail="Wallet not initialized") + raise HTTPException(status_code=401, detail="Invalid or unauthorized token") return AuthenticatedAgent( token=token, diff --git a/src/omniclaw/agent/models.py b/src/omniclaw/agent/models.py index df93070..dda333f 100644 --- a/src/omniclaw/agent/models.py +++ b/src/omniclaw/agent/models.py @@ -166,3 +166,22 @@ class HealthResponse(BaseModel): status: str version: str = "1.0.0" + + +class X402PayRequest(BaseModel): + """X402 Payment request.""" + + url: str = Field(..., description="x402 Service URL") + method: str = Field("GET", description="HTTP method") + body: str | None = Field(None, description="Request body") + headers: dict[str, str] | None = Field(None, description="Request headers") + idempotency_key: str | None = Field(None, description="Idempotency key") + + +class X402VerifyRequest(BaseModel): + """X402 Verification request.""" + + signature: str = Field(..., description="Payment signature/proof") + amount: str = Field(..., description="Amount paid") + sender: str = Field(..., description="Sender address") + resource: str = Field(..., description="Resource URL") diff --git a/src/omniclaw/agent/policy.py b/src/omniclaw/agent/policy.py index dfe8ac5..c265e24 100644 --- a/src/omniclaw/agent/policy.py +++ b/src/omniclaw/agent/policy.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json import os from dataclasses import dataclass, field @@ -70,12 +71,10 @@ def from_dict(cls, data: dict | None) -> RecipientConfig: @dataclass class Policy: - """Main policy configuration for the single agent wallet.""" - version: str = "2.0" - limits: WalletLimits = field(default_factory=WalletLimits) - rate_limits: RateLimits = field(default_factory=RateLimits) - recipients: RecipientConfig = field(default_factory=RecipientConfig) - confirm_threshold: Decimal | None = None + """Main policy configuration for the agent economy.""" + version: str = "0.0.2" + tokens: dict[str, dict[str, Any]] = field(default_factory=dict) + wallets: dict[str, dict[str, Any]] = field(default_factory=dict) @classmethod def from_dict(cls, data: dict | None) -> Policy: @@ -83,21 +82,20 @@ def from_dict(cls, data: dict | None) -> Policy: return cls() return cls( version=data.get("version", "2.0"), - limits=WalletLimits.from_dict(data.get("limits")), - rate_limits=RateLimits.from_dict(data.get("rate_limits")), - recipients=RecipientConfig.from_dict(data.get("recipients")), - confirm_threshold=Decimal(data.get("confirm_threshold", "0")) if data.get("confirm_threshold") else None, + tokens=data.get("tokens", {}), + wallets=data.get("wallets", {}), ) class PolicyManager: - """Manages policy loading, validation, and wallet operations.""" + """Manages policy loading, validation, and multi-agent token mapping.""" def __init__(self, policy_path: str | None = None): self._policy_path = policy_path or os.environ.get( "OMNICLAW_AGENT_POLICY_PATH", "/config/policy.json" ) self._policy: Policy | None = None - self._wallet_id: str | None = None + self._token_to_wallet_id: dict[str, str] = {} + self._wallet_id_to_config: dict[str, dict[str, Any]] = {} self._logger = logger async def load(self) -> Policy: @@ -112,104 +110,123 @@ async def load(self) -> Policy: data = json.load(f) self._policy = Policy.from_dict(data) - self._logger.info("Loaded agent policy configuration.") + self._logger.info("Loaded agent economy policy configuration.") return self._policy - def get_policy(self) -> Policy: - """Get current policy.""" - if not self._policy: - raise RuntimeError("Policy not loaded") - return self._policy + def get_token_map(self) -> dict[str, dict[str, Any]]: + return self._policy.tokens if self._policy else {} - def get_wallet_id(self) -> str | None: - return self._wallet_id + def get_wallet_map(self) -> dict[str, dict[str, Any]]: + return self._policy.wallets if self._policy else {} - def set_wallet_id(self, wallet_id: str) -> None: - """Set wallet ID after creation.""" - self._wallet_id = wallet_id - self._logger.info(f"Set primary agent wallet ID to '{wallet_id}'") + def set_mapping(self, token: str, wallet_id: str, config: dict[str, Any]) -> None: + self._token_to_wallet_id[token] = wallet_id + self._wallet_id_to_config[wallet_id] = config - def is_valid_recipient(self, recipient: str) -> bool: - """Check if recipient is allowed for wallet.""" - if not self._policy: - return True # No policy means allow all + def get_wallet_id_for_token(self, token: str) -> str | None: + return self._token_to_wallet_id.get(token) - recipients = self._policy.recipients - if not recipients.addresses and not recipients.domains: - return True # No restrictions + def is_valid_recipient(self, recipient: str, wallet_id: str) -> bool: + """Check if recipient is allowed for a specific wallet.""" + config = self._wallet_id_to_config.get(wallet_id, {}) + recipient_cfg = RecipientConfig.from_dict(config.get("recipients")) + + if not recipient_cfg.addresses and not recipient_cfg.domains: + return True - if recipient in recipients.addresses: - return recipients.mode == "whitelist" + if recipient in recipient_cfg.addresses: + return recipient_cfg.mode == "whitelist" if recipient.startswith("http"): - for domain in recipients.domains: + for domain in recipient_cfg.domains: if domain in recipient: - return recipients.mode == "whitelist" - - return recipients.mode != "whitelist" + return recipient_cfg.mode == "whitelist" - def check_limits(self, amount: Decimal) -> tuple[bool, str | None]: - """Check if amount is within limits.""" - if not self._policy: - return True, None + return recipient_cfg.mode != "whitelist" - limits = self._policy.limits + def check_limits(self, amount: Decimal, wallet_id: str) -> tuple[bool, str | None]: + config = self._wallet_id_to_config.get(wallet_id, {}) + limits = WalletLimits.from_dict(config.get("limits")) if limits.per_tx_max and amount > limits.per_tx_max: return False, f"Amount {amount} exceeds per_tx_max {limits.per_tx_max}" - if limits.per_tx_min and amount < limits.per_tx_min: - return False, f"Amount {amount} below per_tx_min {limits.per_tx_min}" - return True, None - def requires_confirmation(self, amount: Decimal) -> bool: - """Check if payment requires confirmation.""" - if not self._policy: - return False - threshold = self._policy.confirm_threshold - if not threshold: - return False - return amount >= threshold + def requires_confirmation(self, amount: Decimal, wallet_id: str) -> bool: + config = self._wallet_id_to_config.get(wallet_id, {}) + threshold = Decimal(config.get("confirm_threshold", "0")) + return threshold > 0 and amount >= threshold class WalletManager: - """Manages wallet creation based on policy.""" + """Manages secure wallet mapping from policy tokens.""" def __init__(self, policy_manager: PolicyManager, omniclaw_client: Any): self._policy = policy_manager self._client = omniclaw_client self._logger = logger async def initialize_wallets(self) -> dict[str, str]: - """Ensure the single agent wallet exists.""" - try: - wallet_id = os.environ.get("OMNICLAW_AGENT_WALLET_ID") - if wallet_id: - wallet = await self._client.get_wallet(wallet_id) - else: - wallet_set, wallet = await self._client.create_agent_wallet( - agent_name="omniclaw-primary-agent", + """Initialize all wallets defined in the policy mapping (Parallel).""" + token_map = self._policy.get_token_map() + wallet_map = self._policy.get_wallet_map() + results = {} + + # PHASE 1: Pre-populate token map with alias so Auth works immediately (stateless) + for token, config in token_map.items(): + alias = config.get("wallet_alias", "primary") + # We don't have the real wallet_id yet, but we map it to a placeholder + # so the Agent isn't rejected with "Unauthorized" + self._policy.set_mapping(token, f"pending-{alias}", wallet_map.get(alias, {})) + + # PHASE 2: Perform the intensive SDK/Network calls in PARALLEL + async def init_one(token: str, config: dict[str, Any]): + alias = config.get("wallet_alias", "primary") + wallet_cfg = wallet_map.get(alias, {}) + try: + # 10/10 RESILIENCE: Explicitly handle async/sync transitions to prevent unpacking errors + coro = self._client.create_agent_wallet( + agent_name=f"omniclaw-{alias}", apply_default_guards=False, ) - - self._policy.set_wallet_id(wallet.id) - self._logger.info(f"Wallet successfully initialized: {wallet.id}") - return {"status": "success", "wallet_id": wallet.id} - except Exception as e: - self._logger.error(f"Failed to initialize agent wallet: {e}") - return {"status": "error", "message": str(e)} - - async def get_wallet_address(self) -> str | None: + + # Check for double-wrapping or mismatched SDK versions + if asyncio.iscoroutine(coro): + res = await coro + else: + res = coro # Should not happen if SDK is correctly async + + wallet_set, wallet = res + # Success! Overwrite the placeholder with the real wallet ID + self._policy.set_mapping(token, wallet.id, wallet_cfg) + self._logger.info(f"Successfully initialized wallet '{wallet.id}' for agent '{alias}'") + return token, wallet.id + except Exception as e: + self._logger.error(f"Failed to initialize wallet for '{alias}': {e}") + return token, None + + # Gather all parallel tasks + tasks = [init_one(token, config) for token, config in token_map.items()] + batch_results = await asyncio.gather(*tasks) + + # Collect results + for token, wallet_id in batch_results: + if wallet_id: + results[token] = wallet_id + + return results + + async def get_wallet_address(self, wallet_id: str) -> str | None: """Get wallet address.""" - wallet_id = self._policy.get_wallet_id() - if not wallet_id: + try: + wallet = await self._client.get_wallet(wallet_id) + return wallet.address if wallet else None + except Exception: return None - wallet = await self._client.get_wallet(wallet_id) - return wallet.address if wallet else None - async def get_wallet_balance(self) -> Decimal | None: + async def get_wallet_balance(self, wallet_id: str) -> Decimal | None: """Get wallet balance.""" - wallet_id = self._policy.get_wallet_id() - if not wallet_id: + try: + return await self._client.get_balance(wallet_id) + except Exception: return None - return await self._client.get_balance(wallet_id) diff --git a/src/omniclaw/agent/routes.py b/src/omniclaw/agent/routes.py index 027f7f3..efd99bb 100644 --- a/src/omniclaw/agent/routes.py +++ b/src/omniclaw/agent/routes.py @@ -70,7 +70,10 @@ async def get_address( agent: AuthenticatedAgent = Depends(get_current_agent), wallet_mgr: WalletManager = Depends(get_wallet_manager), ): - address = await wallet_mgr.get_wallet_address() + if agent.wallet_id.startswith("pending-"): + raise HTTPException(status_code=425, detail="Wallet is currently initializing. Please try again in a few seconds.") + + address = await wallet_mgr.get_wallet_address(agent.wallet_id) if not address: raise HTTPException(status_code=404, detail="Wallet not found") @@ -86,7 +89,10 @@ async def get_balance( agent: AuthenticatedAgent = Depends(get_current_agent), wallet_mgr: WalletManager = Depends(get_wallet_manager), ): - balance = await wallet_mgr.get_wallet_balance() + if agent.wallet_id.startswith("pending-"): + raise HTTPException(status_code=425, detail="Wallet is currently initializing. Please try again in a few seconds.") + + balance = await wallet_mgr.get_wallet_balance(agent.wallet_id) if balance is None: raise HTTPException(status_code=404, detail="Wallet not found") @@ -104,15 +110,18 @@ async def pay( policy_mgr: PolicyManager = Depends(get_policy_manager), client: "OmniClaw" = Depends(get_omniclaw_client), ): - if not policy_mgr.is_valid_recipient(request.recipient): + if agent.wallet_id.startswith("pending-"): + raise HTTPException(status_code=425, detail="Wallet is currently initializing. Please try again in a few seconds.") + + if not policy_mgr.is_valid_recipient(request.recipient, agent.wallet_id): raise HTTPException(status_code=400, detail="Recipient not allowed by policy") amount = Decimal(request.amount) - allowed, reason = policy_mgr.check_limits(amount) + allowed, reason = policy_mgr.check_limits(amount, agent.wallet_id) if not allowed: raise HTTPException(status_code=400, detail=reason) - requires_confirmation = policy_mgr.requires_confirmation(amount) + requires_confirmation = policy_mgr.requires_confirmation(amount, agent.wallet_id) try: result = await client.pay( @@ -159,11 +168,14 @@ async def simulate( policy_mgr: PolicyManager = Depends(get_policy_manager), client: "OmniClaw" = Depends(get_omniclaw_client), ): - if not policy_mgr.is_valid_recipient(request.recipient): + if agent.wallet_id.startswith("pending-"): + return SimulateResponse(would_succeed=False, route="TRANSFER", reason="Wallet is currently initializing") + + if not policy_mgr.is_valid_recipient(request.recipient, agent.wallet_id): return SimulateResponse(would_succeed=False, route="TRANSFER", reason="Recipient not allowed by policy") amount = Decimal(request.amount) - allowed, reason = policy_mgr.check_limits(amount) + allowed, reason = policy_mgr.check_limits(amount, agent.wallet_id) if not allowed: return SimulateResponse(would_succeed=False, route="TRANSFER", reason=reason) @@ -363,20 +375,24 @@ async def list_wallets( policy_mgr: PolicyManager = Depends(get_policy_manager), wallet_mgr: WalletManager = Depends(get_wallet_manager), ): - address = await wallet_mgr.get_wallet_address() - wallet_id = policy_mgr.get_wallet_id() - policy = policy_mgr.get_policy() + is_pending = agent.wallet_id.startswith("pending-") + address = None + if not is_pending: + address = await wallet_mgr.get_wallet_address(agent.wallet_id) - # Send a mock policy block for the CLI display - policy_dict = policy.to_dict() + # Get policy block for the CLI display + # We use agent.wallet_id if not pending, otherwise we look up the alias + alias = agent.wallet_id.replace("pending-", "") + policy_map = policy_mgr.get_wallet_map() + wallet_policy = policy_map.get(alias, {}) wallets = [ WalletInfo( - alias="primary", - wallet_id=wallet_id or "", - address=address or "", + alias=alias, + wallet_id=agent.wallet_id, + address=address or ("INITIALIZING..." if is_pending else "NONE"), fund_address=address, - policy=policy_dict, + policy=wallet_policy, ) ] diff --git a/src/omniclaw/agent/server.py b/src/omniclaw/agent/server.py index 2b8169e..8a22096 100644 --- a/src/omniclaw/agent/server.py +++ b/src/omniclaw/agent/server.py @@ -6,6 +6,7 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources") warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") +import asyncio import logging from contextlib import asynccontextmanager from typing import Any @@ -50,8 +51,11 @@ async def lifespan(app: FastAPI): # Initialize wallet manager wallet_mgr = WalletManager(policy_mgr, client) - wallet_results = await wallet_mgr.initialize_wallets() - logger.info(f"Initialized agent wallet: {wallet_results}") + + # PRODUCITON RESILIENCE: Run wallet initialization in the background + # This prevents Circle API timeouts from blocking the Control Plane startup + logger.info("OmniClaw background initialization started (non-blocking)...") + asyncio.create_task(wallet_mgr.initialize_wallets()) # Initialize token auth auth = TokenAuth(policy_mgr) diff --git a/src/omniclaw/cli_agent.py b/src/omniclaw/cli_agent.py index b9b4824..a3b51a8 100644 --- a/src/omniclaw/cli_agent.py +++ b/src/omniclaw/cli_agent.py @@ -2,9 +2,11 @@ import warnings -# Suppress deprecation warnings from downstream dependencies (e.g. web3 using pkg_resources) -warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources") +# Aggressively suppress noisy deprecation warnings from downstream dependencies (e.g. web3, circle-sdk) +# This must happen before any third-party imports. warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*") +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning, module="web3") import base64 import json diff --git a/src/omniclaw/client.py b/src/omniclaw/client.py index defc6bc..09b572f 100644 --- a/src/omniclaw/client.py +++ b/src/omniclaw/client.py @@ -272,6 +272,24 @@ def _init_nanopayments(self) -> None: return try: + # Pre-import and warmup Circle SDK to resolve all lazy modules before + # any parallel threads (asyncio.to_thread) try to import concurrently. + # The Circle Python SDK uses a lazy module loader that breaks when + # multiple threads trigger concurrent resolution of the same lazy module. + try: + from circle.web3 import developer_controlled_wallets, utils + + warmup_client = utils.init_developer_controlled_wallets_client( + api_key=self._config.circle_api_key, + entity_secret=self._config.entity_secret, + ) + # Force resolution of all lazy submodules by instantiating API classes + developer_controlled_wallets.WalletSetsApi(warmup_client) + developer_controlled_wallets.WalletsApi(warmup_client) + developer_controlled_wallets.TransactionsApi(warmup_client) + except Exception as warmup_exc: + self._logger.debug(f"Circle SDK warmup: {warmup_exc}") + import httpx from omniclaw.protocols.nanopayments import ( @@ -405,7 +423,9 @@ async def premium(payment=Depends(omniclaw.gateway().require("$0.001"))): return self._gateway_middleware # For Circle, we need nanopayments initialized - if (facilitator is None or facilitator == "circle") and (not self._nano_client or not self._nano_vault): + if (facilitator is None or facilitator == "circle") and ( + not self._nano_client or not self._nano_vault + ): raise NanopaymentNotInitializedError() # If no seller_address provided, try to get from wallet @@ -496,6 +516,7 @@ def base_dependency_factory(): seller_address=seller_address, facilitator=facilitator, ) + price_str = price async def wrapper() -> PaymentInfo: @@ -913,25 +934,52 @@ async def create_agent_wallet( Returns: Tuple of (wallet_set, wallet_info) """ - wallet_set, wallet = self._wallet_service.setup_agent_wallet( - agent_name=agent_name, - blockchain=blockchain, + # 10/10 RESILIENCE: setup_agent_wallet is SYNC and blocks. + # Offload to a thread so asyncio.gather can work in parallel. + # Use positional arguments for maximum compatibility across Python versions. + wallet_set, wallet = await asyncio.to_thread( + self._wallet_service.setup_agent_wallet, + agent_name, + blockchain, ) if apply_default_guards: await self.apply_default_guards(wallet.id) - # Create nanopayment key so gateway operations work + # 10/10 RESILIENCE: Key generation can fail on slow RPCs (free Base nodes). + # We retry with exponential backoff to ensure the agent is ready. if self._nano_vault: key_alias = f"wallet-{wallet.id}" - try: - address = await self._nano_vault.generate_key(key_alias) - self._logger.info( - f"Generated nanopayment key for wallet '{wallet.id}': " - f"alias={key_alias}, address={address}" - ) - except Exception as e: - self._logger.warning(f"Could not create nanopayment key: {e}") + max_retries = 5 + base_delay = 3 + + for attempt in range(max_retries): + try: + address = await self._nano_vault.generate_key(key_alias) + self._logger.info( + f"Generated nanopayment key for wallet '{wallet.id}': " + f"alias={key_alias}, address={address} (Attempt {attempt + 1})" + ) + break + except Exception as e: + error_msg = str(e).lower() + if "already exists" in error_msg: + self._logger.info( + f"Nanopayment key already exists for wallet '{wallet.id}': " + f"alias={key_alias}. Recovery successful." + ) + break + + if attempt == max_retries - 1: + self._logger.warning( + f"Final attempt failed to create nanopayment key: {e}. Wallet will start in Degraded mode." + ) + else: + delay = base_delay * (2**attempt) + self._logger.warning( + f"Nanopayment key generation failed (Attempt {attempt + 1}): {e}. Retrying in {delay}s..." + ) + await asyncio.sleep(delay) return wallet_set, wallet diff --git a/src/omniclaw/core/circle_client.py b/src/omniclaw/core/circle_client.py index 58934d1..9801649 100644 --- a/src/omniclaw/core/circle_client.py +++ b/src/omniclaw/core/circle_client.py @@ -8,16 +8,16 @@ from __future__ import annotations import uuid +import threading from typing import Any -from circle.web3 import developer_controlled_wallets, utils - from omniclaw.core.config import Config from omniclaw.core.exceptions import ( ConfigurationError, NetworkError, WalletError, ) +from omniclaw.core.logging import get_logger from omniclaw.core.types import ( AccountType, Balance, @@ -34,8 +34,16 @@ class CircleClient: def __init__(self, config: Config) -> None: self._config = config + self._logger = get_logger("circle_client") + self._lock = threading.Lock() try: + # Import Circle SDK lazily to avoid circular import with lazy module loader + from circle.web3 import developer_controlled_wallets, utils + + self._dcl = developer_controlled_wallets + self._utils = utils + # Initialize the Circle SDK client self._client = utils.init_developer_controlled_wallets_client( api_key=config.circle_api_key, @@ -48,9 +56,19 @@ def __init__(self, config: Config) -> None: ) from e # Initialize API instances - self._wallet_sets_api = developer_controlled_wallets.WalletSetsApi(self._client) - self._wallets_api = developer_controlled_wallets.WalletsApi(self._client) - self._transactions_api = developer_controlled_wallets.TransactionsApi(self._client) + self._wallet_sets_api = self._dcl.WalletSetsApi(self._client) + self._wallets_api = self._dcl.WalletsApi(self._client) + self._transactions_api = self._dcl.TransactionsApi(self._client) + + # 10/10 RESILIENCE: The Circle Python SDK has a thread-safety race condition + # in its lazy loader. We force resolution in the main thread by calling + # a harmless method before any parallel threads are spawned. + try: + with self._lock: + # Harmless call to warm up the configurations + _ = self._wallet_sets_api.get_wallet_sets(page_size=1) + except Exception as e: + self._logger.debug(f"SDK Warmup (get_wallet_sets): {e}") # ==================== Wallet Set Operations ==================== @@ -66,14 +84,14 @@ def list_wallet_sets(self) -> list[WalletSetInfo]: return wallet_sets - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to list wallet sets: {e}", details={"api_error": str(e)}, ) from e def _get_ciphertext(self) -> str: - return utils.generate_entity_secret_ciphertext( + return self._utils.generate_entity_secret_ciphertext( api_key=self._config.circle_api_key, entity_secret_hex=self._config.entity_secret, ) @@ -85,12 +103,20 @@ def get_wallet_set(self, wallet_set_id: str) -> WalletSetInfo: ws_data = response.data.wallet_set.actual_instance.to_dict() return WalletSetInfo.from_api_response(ws_data) - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to get wallet set {wallet_set_id}: {e}", details={"api_error": str(e), "wallet_set_id": wallet_set_id}, ) from e + def find_wallet_set_by_name(self, name: str) -> WalletSetInfo | None: + """Find a wallet set by its human-readable name.""" + wallet_sets = self.list_wallet_sets() + for ws in wallet_sets: + if ws.name == name: + return ws + return None + # ==================== Wallet Operations ==================== def create_wallet_set( @@ -99,23 +125,26 @@ def create_wallet_set( ) -> WalletSetInfo: """Create a new wallet set.""" try: - ciphertext = self._get_ciphertext() - idempotency_key = str(uuid.uuid4()) + with self._lock: + ciphertext = self._get_ciphertext() - request = developer_controlled_wallets.CreateWalletSetRequest.from_dict( - { - "name": name, - "idempotencyKey": idempotency_key, - "entitySecretCiphertext": ciphertext, - } - ) - response = self._wallet_sets_api.create_wallet_set(request) + # 10/10 IDEMPOTENCY: Derive UUID from name to ensure Circle reuses existing set + idempotency_key = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"omniclaw-set-{name}")) - # Extract wallet set data from response - wallet_set_data = response.data.wallet_set - return WalletSetInfo.from_api_response(wallet_set_data.to_dict()) + request = self._dcl.CreateWalletSetRequest.from_dict( + { + "name": name, + "idempotencyKey": idempotency_key, + "entitySecretCiphertext": ciphertext, + } + ) + response = self._wallet_sets_api.create_wallet_set(request) + + # Extract wallet set data from response + wallet_set_data = response.data.wallet_set + return WalletSetInfo.from_api_response(wallet_set_data.to_dict()) - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to create wallet set: {e}", details={"api_error": str(e), "name": name}, @@ -144,22 +173,29 @@ def create_wallets( blockchain_str = blockchain.value if isinstance(blockchain, Network) else blockchain try: - ciphertext = self._get_ciphertext() - idempotency_key = str(uuid.uuid4()) + with self._lock: + ciphertext = self._get_ciphertext() + + # 10/10 IDEMPOTENCY: Derive UUID from set + blockchain to ensure reuse + idempotency_key = str( + uuid.uuid5( + uuid.NAMESPACE_DNS, f"omniclaw-wallet-{wallet_set_id}-{blockchain_str}" + ) + ) - request = developer_controlled_wallets.CreateWalletRequest.from_dict( - { - "walletSetId": wallet_set_id, - "blockchains": [blockchain_str], - "count": count, - "accountType": account_type.value - if hasattr(account_type, "value") - else str(account_type), - "idempotencyKey": idempotency_key, - "entitySecretCiphertext": ciphertext, - } - ) - response = self._wallets_api.create_wallet(request) + request = self._dcl.CreateWalletRequest.from_dict( + { + "walletSetId": wallet_set_id, + "blockchains": [blockchain_str], + "count": count, + "accountType": account_type.value + if hasattr(account_type, "value") + else str(account_type), + "idempotencyKey": idempotency_key, + "entitySecretCiphertext": ciphertext, + } + ) + response = self._wallets_api.create_wallet(request) wallets = [] for wallet in response.data.wallets: @@ -168,7 +204,7 @@ def create_wallets( return wallets - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to create wallets: {e}", details={ @@ -186,7 +222,7 @@ def get_wallet(self, wallet_id: str) -> WalletInfo: wallet_data = response.data.wallet.actual_instance.to_dict() return WalletInfo.from_api_response(wallet_data) - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to get wallet {wallet_id}: {e}", wallet_id=wallet_id, @@ -217,7 +253,7 @@ def list_wallets( return wallets - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to list wallets: {e}", details={"api_error": str(e), "wallet_set_id": wallet_set_id}, @@ -238,7 +274,7 @@ def get_wallet_balances(self, wallet_id: str) -> list[Balance]: return balances - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to get wallet balances: {e}", wallet_id=wallet_id, @@ -283,18 +319,16 @@ def create_transfer( ciphertext = self._get_ciphertext() # Use correct SDK request class for developer wallets - request = ( - developer_controlled_wallets.CreateTransferTransactionForDeveloperRequest.from_dict( - { - "idempotencyKey": idempotency_key, - "entitySecretCiphertext": ciphertext, - "walletId": wallet_id, - "tokenId": token_id, - "destinationAddress": destination_address, - "amounts": [amount], - "feeLevel": fee_level.value, # Fee level at top level, not nested - } - ) + request = self._dcl.CreateTransferTransactionForDeveloperRequest.from_dict( + { + "idempotencyKey": idempotency_key, + "entitySecretCiphertext": ciphertext, + "walletId": wallet_id, + "tokenId": token_id, + "destinationAddress": destination_address, + "amounts": [amount], + "feeLevel": fee_level.value, # Fee level at top level, not nested + } ) # Use correct API method for developer wallets response = self._transactions_api.create_developer_transaction_transfer(request) @@ -302,7 +336,7 @@ def create_transfer( tx_data = response.data.to_dict() return TransactionInfo.from_api_response(tx_data) - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to create transfer: {e}", wallet_id=wallet_id, @@ -320,7 +354,7 @@ def get_transaction(self, transaction_id: str) -> TransactionInfo: tx_data = response.data.transaction.to_dict() return TransactionInfo.from_api_response(tx_data) - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise NetworkError( f"Failed to get transaction {transaction_id}: {e}", details={"api_error": str(e), "transaction_id": transaction_id}, @@ -349,7 +383,7 @@ def list_transactions( return transactions - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise NetworkError( f"Failed to list transactions: {e}", details={"api_error": str(e)}, @@ -398,7 +432,7 @@ def create_contract_execution( ciphertext = self._get_ciphertext() - request = developer_controlled_wallets.CreateContractExecutionTransactionForDeveloperRequest.from_dict( + request = self._dcl.CreateContractExecutionTransactionForDeveloperRequest.from_dict( { "idempotencyKey": idempotency_key, "entitySecretCiphertext": ciphertext, @@ -416,7 +450,7 @@ def create_contract_execution( tx_data = response.data.to_dict() return TransactionInfo.from_api_response(tx_data) - except developer_controlled_wallets.ApiException as e: + except self._dcl.ApiException as e: raise WalletError( f"Failed to execute contract: {e}", wallet_id=wallet_id, diff --git a/src/omniclaw/core/config.py b/src/omniclaw/core/config.py index dd21b97..c1f6abc 100644 --- a/src/omniclaw/core/config.py +++ b/src/omniclaw/core/config.py @@ -47,7 +47,7 @@ class Config: gateway_api_url: str = "https://gateway-api-testnet.circle.com/v1" # Timeouts (seconds) - request_timeout: float = 30.0 + request_timeout: float = 60.0 transaction_poll_interval: float = 2.0 transaction_poll_timeout: float = 120.0 diff --git a/src/omniclaw/core/types.py b/src/omniclaw/core/types.py index bc2fd73..233b375 100644 --- a/src/omniclaw/core/types.py +++ b/src/omniclaw/core/types.py @@ -295,8 +295,9 @@ class WalletSetInfo: id: str custody_type: CustodyType - create_date: datetime - update_date: datetime + name: str | None = None + create_date: datetime | None = None + update_date: datetime | None = None @classmethod def from_api_response(cls, data: dict[str, Any]) -> "WalletSetInfo": @@ -312,6 +313,7 @@ def parse_dt(val: str | datetime | None) -> datetime | None: return cls( id=data["id"], custody_type=CustodyType(data["custodyType"]), + name=data.get("name"), create_date=parse_dt(data.get("createDate")), update_date=parse_dt(data.get("updateDate")), ) diff --git a/src/omniclaw/onboarding.py b/src/omniclaw/onboarding.py index 40dd088..039aa31 100644 --- a/src/omniclaw/onboarding.py +++ b/src/omniclaw/onboarding.py @@ -34,14 +34,21 @@ if TYPE_CHECKING: from logging import Logger -# Circle SDK utilities for entity secret management -try: - from circle.web3 import utils as circle_utils +# Circle SDK availability check (fully deferred to avoid circular import) +CIRCLE_SDK_AVAILABLE: bool | None = None - CIRCLE_SDK_AVAILABLE = True -except ImportError: - CIRCLE_SDK_AVAILABLE = False - circle_utils = None + +def _check_circle_sdk() -> bool: + """Lazily check if Circle SDK is installed.""" + global CIRCLE_SDK_AVAILABLE + if CIRCLE_SDK_AVAILABLE is None: + try: + import importlib.util + + CIRCLE_SDK_AVAILABLE = importlib.util.find_spec("circle.web3") is not None + except Exception: + CIRCLE_SDK_AVAILABLE = False + return CIRCLE_SDK_AVAILABLE MANAGED_CREDENTIALS_FILE = "credentials.json" @@ -193,7 +200,7 @@ def load_managed_entity_secret(api_key: str) -> str | None: def resolve_entity_secret(api_key: str | None = None) -> str | None: """ Find the best available entity secret for the current session. - + Resolution order: 1. OS environment (ENTITY_SECRET) 2. Managed store (matching CIRCLE_API_KEY) @@ -202,12 +209,12 @@ def resolve_entity_secret(api_key: str | None = None) -> str | None: env_secret = os.getenv("ENTITY_SECRET") if env_secret: return env_secret - + # 2. Managed store fallback resolved_api_key = api_key or os.getenv("CIRCLE_API_KEY") if resolved_api_key: return load_managed_entity_secret(resolved_api_key) - + return None @@ -294,7 +301,7 @@ def register_entity_secret( Raises: SetupError: If Circle SDK not installed or registration fails """ - if not CIRCLE_SDK_AVAILABLE: + if not _check_circle_sdk(): raise SetupError( "Circle SDK not installed. Run: pip install circle-developer-controlled-wallets" ) @@ -319,6 +326,8 @@ def register_entity_secret( existing_files = set(recovery_dir.glob("recovery_file_*.dat")) try: + from circle.web3 import utils as circle_utils + result = circle_utils.register_entity_secret_ciphertext( api_key=api_key, entity_secret=entity_secret, @@ -612,7 +621,7 @@ def verify_setup() -> dict[str, bool]: Dict with status of each requirement and 'ready' boolean """ results = { - "circle_sdk_installed": CIRCLE_SDK_AVAILABLE, + "circle_sdk_installed": _check_circle_sdk(), "api_key_set": bool(os.getenv("CIRCLE_API_KEY")), "entity_secret_set": bool(os.getenv("ENTITY_SECRET")), } @@ -662,8 +671,8 @@ def doctor( warnings.append("Environment ENTITY_SECRET does not match the managed config copy.") return { - "ready": bool(resolved_api_key and active_secret and CIRCLE_SDK_AVAILABLE), - "circle_sdk_installed": CIRCLE_SDK_AVAILABLE, + "ready": bool(resolved_api_key and active_secret and _check_circle_sdk()), + "circle_sdk_installed": _check_circle_sdk(), "config_dir": str(config_dir), "managed_credentials_path": str(credentials_path), "api_key_set": bool(resolved_api_key), diff --git a/src/omniclaw/storage/redis.py b/src/omniclaw/storage/redis.py index 108a577..918f43a 100644 --- a/src/omniclaw/storage/redis.py +++ b/src/omniclaw/storage/redis.py @@ -47,11 +47,23 @@ def _get_client(self): if self._client is None: try: import redis.asyncio as redis + from redis.backoff import ExponentialBackoff + from redis.retry import Retry except ImportError: raise ImportError( "redis package required for RedisStorage. Install with: pip install redis" ) from None - self._client = redis.from_url(self._redis_url, decode_responses=True) + self._client = redis.from_url( + self._redis_url, + decode_responses=True, + socket_timeout=10.0, + socket_connect_timeout=10.0, + retry_on_timeout=True, + retry=Retry( + backoff=ExponentialBackoff(base=1, cap=5), + retries=3, + ), + ) return self._client def _make_key(self, collection: str, key: str) -> str: diff --git a/src/omniclaw/wallet/service.py b/src/omniclaw/wallet/service.py index 03d4383..57479c6 100644 --- a/src/omniclaw/wallet/service.py +++ b/src/omniclaw/wallet/service.py @@ -223,11 +223,11 @@ def create_agent_wallet( """ target_name = f"agent-{agent_name}" - # Check if wallet set exists - # Note: Circle API no longer returns wallet set names, so we always create a new set. + # 10/10 IDEMPOTENCY: CircleClient now uses deterministic UUIDs based on names. + # This means create_wallet_set will return the EXISTING set if it was already created. wallet_set = self.create_wallet_set(name=target_name) - # Create wallet(s) + # Create wallet(s) - also idempotent via blockchain+set name if count == 1: wallet = self.create_wallet(wallet_set_id=wallet_set.id, blockchain=blockchain) return wallet_set, wallet @@ -537,7 +537,9 @@ def get_or_create_default_wallet_set( Returns: Wallet set info """ - # Circle API no longer returns wallet set names, so we always create a new set. + existing = self._circle.find_wallet_set_by_name(name) + if existing: + return existing return self.create_wallet_set(name) def setup_agent_wallet( @@ -557,13 +559,15 @@ def setup_agent_wallet( Returns: Tuple of (wallet_set, wallet) """ - wallet_set = self.create_wallet_set(agent_name) - wallet = self.create_wallet( - wallet_set_id=wallet_set.id, + # Simply use the underlying create_agent_wallet logic for consistency + wallet_set, wallet_or_list = self.create_agent_wallet( + agent_name=agent_name, blockchain=blockchain, + count=1 ) - - return wallet_set, wallet + + # We know it's a single wallet because count=1 + return wallet_set, wallet_or_list # type: ignore def clear_cache(self) -> None: """Clear the wallet cache."""