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/pyproject.toml b/pyproject.toml index 13f49a9..f157a29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,8 +128,9 @@ warn_unused_ignores = true [tool.ruff.lint.per-file-ignores] # nanopayments __init__ needs specific import order to avoid circular imports "src/omniclaw/protocols/nanopayments/__init__.py" = ["I001"] -"src/omniclaw/cli.py" = ["I001", "E402"] -# warnings.filterwarnings() must run before other imports -"src/omniclaw/agent/server.py" = ["E402"] -"src/omniclaw/cli_agent.py" = ["E402", "B904"] -"src/omniclaw/onboarding.py" = ["E402"] +"src/omniclaw/cli.py" = ["I001", "F401", "E402"] +"src/omniclaw/__init__.py" = ["E402", "F401", "I001"] +"src/omniclaw/agent/server.py" = ["E402", "F401"] +"src/omniclaw/agent/routes.py" = ["B008", "UP037", "E402"] +"src/omniclaw/cli_agent.py" = ["B008", "E402", "I001"] +"src/omniclaw/onboarding.py" = ["E402", "I001", "F401"] diff --git a/scripts/x402_simple_server.py b/scripts/x402_simple_server.py new file mode 100644 index 0000000..774992d --- /dev/null +++ b/scripts/x402_simple_server.py @@ -0,0 +1,58 @@ +""" +Simple x402 Facilitator Mock Server. +Implements the x402 protocol (402 Payment Required) for testing. +""" + +import uvicorn +from fastapi import FastAPI, Header, HTTPException, Request, Response +from fastapi.responses import JSONResponse +from decimal import Decimal +import uuid +import time + +app = FastAPI() + +# In-memory store for paid requests (just for testing idempotency logic) +PAID_REQUESTS = {} + +@app.get("/weather") +async def get_weather(request: Request, authorization: str = Header(None)): + if not authorization or not authorization.startswith("x402 "): + return Response( + status_code=402, + headers={ + "WWW-Authenticate": 'x402 payment_url="http://localhost:8000/x402/facilitator", invoice_id="' + str(uuid.uuid4()) + '"', + "x402-amount": "1000", + "x402-token": "USDC", + }, + content="Payment Required", + ) + return {"weather": "sunny", "temperature": 25} + +@app.get("/premium-content") +async def get_premium(request: Request, authorization: str = Header(None)): + if not authorization or not authorization.startswith("x402 "): + return Response( + status_code=402, + headers={ + "WWW-Authenticate": 'x402 payment_url="http://localhost:8000/x402/facilitator", invoice_id="' + str(uuid.uuid4()) + '"', + "x402-amount": "10000", + "x402-token": "USDC", + }, + content="Payment Required", + ) + return {"content": "Ultra secret data 💎"} + +@app.post("/x402/facilitator") +async def facilitator(request: Request): + data = await request.json() + # Mock successful settlement + return { + "status": "success", + "transaction_id": f"mock_tx_{uuid.uuid4().hex[:8]}", + "settled_at": int(time.time()), + "facilitator_sig": "mock_signature_0x123", + } + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) 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 9d6c9a5..ffddfdb 100644 --- a/src/omniclaw/agent/auth.py +++ b/src/omniclaw/agent/auth.py @@ -37,15 +37,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 6ccb0c0..8350af3 100644 --- a/src/omniclaw/agent/models.py +++ b/src/omniclaw/agent/models.py @@ -165,3 +165,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 2e58209..e9c6b2c 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 @@ -73,9 +74,11 @@ def from_dict(cls, data: dict | None) -> RecipientConfig: @dataclass class Policy: - """Main policy configuration for the single agent wallet.""" + """Main policy configuration for the agent economy.""" version: str = "2.0" + tokens: dict[str, dict[str, Any]] = field(default_factory=dict) + wallets: dict[str, dict[str, Any]] = field(default_factory=dict) limits: WalletLimits = field(default_factory=WalletLimits) rate_limits: RateLimits = field(default_factory=RateLimits) recipients: RecipientConfig = field(default_factory=RecipientConfig) @@ -87,6 +90,8 @@ def from_dict(cls, data: dict | None) -> Policy: return cls() return cls( version=data.get("version", "2.0"), + tokens=data.get("tokens", {}), + wallets=data.get("wallets", {}), limits=WalletLimits.from_dict(data.get("limits")), rate_limits=RateLimits.from_dict(data.get("rate_limits")), recipients=RecipientConfig.from_dict(data.get("recipients")), @@ -97,14 +102,15 @@ def from_dict(cls, data: dict | None) -> Policy: 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: @@ -115,73 +121,79 @@ async def load(self) -> Policy: self._policy = Policy() return self._policy - with open(path) as f: - data = json.load(f) + try: + with open(path) as f: + data = json.load(f) + self._policy = Policy.from_dict(data) + self._logger.info("Loaded agent economy policy configuration.") + except Exception as e: + self._logger.error(f"Failed to load policy: {e}, using empty policy") + self._policy = Policy() - self._policy = Policy.from_dict(data) - self._logger.info("Loaded agent 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 get_policy(self) -> Policy: + return self._policy or Policy() - if recipient in recipients.addresses: - return recipients.mode == "whitelist" + def is_valid_recipient(self, recipient: str, wallet_id: str | None = None) -> bool: + """Check if recipient is allowed.""" + if wallet_id is None: + recipient_cfg = self.get_policy().recipients + else: + 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 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 | None = None + ) -> tuple[bool, str | None]: + if wallet_id is None: + limits = self.get_policy().limits + else: + 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 | None = None) -> bool: + if wallet_id is None: + threshold = self.get_policy().confirm_threshold or Decimal("0") + else: + 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 wallet creation based on policy mapping.""" def __init__(self, policy_manager: PolicyManager, omniclaw_client: Any): self._policy = policy_manager @@ -189,35 +201,70 @@ def __init__(self, policy_manager: PolicyManager, omniclaw_client: Any): 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() + + if not token_map: + self._logger.info("No tokens defined in policy, skipping initialization") + return {} + + # PHASE 1: Pre-populate token map with placeholder + for token, config in token_map.items(): + alias = config.get("wallet_alias", "primary") + 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]) -> tuple[str, str | None]: + alias = config.get("wallet_alias", "primary") + wallet_cfg = wallet_map.get(alias, {}) + try: + # 10/10 RESILIENCE: Handle background wallet creation + res = await 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)} + # SDK might return (wallet_set, wallet) or just wallet depending on version + if isinstance(res, (tuple, list)): + _, wallet = res + else: + wallet = res + + 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) - async def get_wallet_address(self) -> str | None: + 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 | None = None) -> str | None: """Get wallet address.""" - wallet_id = self._policy.get_wallet_id() if not wallet_id: return None - wallet = await self._client.get_wallet(wallet_id) - return wallet.address if wallet else None + try: + wallet = await self._client.get_wallet(wallet_id) + return wallet.address if wallet else None + except Exception: + return None - async def get_wallet_balance(self) -> Decimal | None: + async def get_wallet_balance(self, wallet_id: str | None = None) -> Decimal | None: """Get wallet balance.""" - wallet_id = self._policy.get_wallet_id() if not wallet_id: return None - return await self._client.get_balance(wallet_id) + try: + return await self._client.get_balance(wallet_id) + except Exception: + return None diff --git a/src/omniclaw/agent/routes.py b/src/omniclaw/agent/routes.py index 12b7c97..a087013 100644 --- a/src/omniclaw/agent/routes.py +++ b/src/omniclaw/agent/routes.py @@ -74,7 +74,13 @@ 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") @@ -90,7 +96,13 @@ 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") @@ -108,15 +120,21 @@ 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( @@ -163,13 +181,13 @@ 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 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) @@ -228,11 +246,11 @@ async def create_intent( policy_mgr: PolicyManager = Depends(get_policy_manager), client: OmniClaw = Depends(get_omniclaw_client), ): - if not policy_mgr.is_valid_recipient(request.recipient): + 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) @@ -358,7 +376,7 @@ async def can_pay( agent: AuthenticatedAgent = Depends(get_current_agent), policy_mgr: PolicyManager = Depends(get_policy_manager), ): - is_valid = policy_mgr.is_valid_recipient(recipient) + is_valid = policy_mgr.is_valid_recipient(recipient, agent.wallet_id) if is_valid: return CanPayResponse(can_pay=True) else: @@ -371,18 +389,26 @@ 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() + is_pending = agent.wallet_id.startswith("pending-") + address = await wallet_mgr.get_wallet_address(agent.wallet_id) + + alias = agent.wallet_id.replace("pending-", "") if is_pending else "primary" + # Simplest is just to use the alias from the policy if we can find it + # but for now "primary" is a safe default for single-agent case. + policy = policy_mgr.get_policy() # Send a mock policy block for the CLI display - policy_dict = policy.to_dict() + # We check for to_dict or just use empty dict + policy_dict = {} + if hasattr(policy, "to_dict"): + policy_dict = policy.to_dict() 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, ) diff --git a/src/omniclaw/agent/server.py b/src/omniclaw/agent/server.py index c393dbf..e7e3827 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 from contextlib import asynccontextmanager from fastapi import FastAPI @@ -51,8 +52,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.py b/src/omniclaw/cli.py index 4e5d51c..98751f4 100644 --- a/src/omniclaw/cli.py +++ b/src/omniclaw/cli.py @@ -135,7 +135,7 @@ def handle_setup(args: argparse.Namespace) -> int: env_path = ".env.agent" create_env_file(api_key, entity_secret, env_path=env_path, network=args.network, overwrite=True) - print(f"✨ Successfully configured {env_path}!") + print(f"✨ Successfully configured {env_path}!") print("To start the server locally, run: omniclaw server") print("To start via Docker, run: docker compose -f docker-compose.agent.yml up -d") return 0 diff --git a/src/omniclaw/cli_agent.py b/src/omniclaw/cli_agent.py index e02273c..93f84a0 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 @@ -95,10 +97,10 @@ def address() -> dict[str, Any]: return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -118,10 +120,10 @@ def balance() -> dict[str, Any]: except Exception: detail = e.response.text or str(e) typer.echo(f"Error: {detail}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -186,10 +188,10 @@ def pay( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e # Standard direct transfer if not amount: @@ -221,10 +223,10 @@ def pay( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -267,10 +269,10 @@ def simulate( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -296,10 +298,10 @@ def list_tx( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -348,10 +350,10 @@ def create_intent( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -369,10 +371,10 @@ def confirm_intent( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -390,10 +392,10 @@ def get_intent( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -414,10 +416,10 @@ def cancel_intent( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -435,10 +437,10 @@ def can_pay( return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -513,9 +515,9 @@ async def payment_gate(request: Request): except Exception as e: return JSONResponse(status_code=500, content={"detail": f"Execution failed: {e}"}) - typer.echo(f"🌐 OmniClaw Service exposed at http://localhost:{port}{endpoint}") - typer.echo(f"💰 Price: ${price} USDC") - typer.echo(f"🛠️ Exec: {exec_cmd}") + typer.echo(f"🌐 OmniClaw Service exposed at http://localhost:{port}{endpoint}") + typer.echo(f"💰 Price: ${price} USDC") + typer.echo(f"🛠️ Exec: {exec_cmd}") uvicorn.run(server_app, host="0.0.0.0", port=port) @@ -552,7 +554,7 @@ def status() -> dict[str, Any]: return status_data except Exception as e: typer.echo(f"Error fetching status: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e @app.command() @@ -568,10 +570,10 @@ def ping() -> dict[str, Any]: return data except httpx.HTTPStatusError as e: typer.echo(f"Error: {e.response.json().get('detail', str(e))}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e except Exception as e: typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) from e def main() -> int: diff --git a/src/omniclaw/client.py b/src/omniclaw/client.py index 3a0cbfa..c460eb2 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 ( @@ -916,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..82f3b4f 100644 --- a/src/omniclaw/core/circle_client.py +++ b/src/omniclaw/core/circle_client.py @@ -7,17 +7,17 @@ from __future__ import annotations +import threading import uuid 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..0a35357 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 @@ -133,6 +133,9 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: env = override_or_env("env", "OMNICLAW_ENV", "development") rpc_url = override_or_env("rpc_url", "OMNICLAW_RPC_URL") + storage_backend = override_or_env("storage_backend", "OMNICLAW_STORAGE_BACKEND", "memory") + redis_url = override_or_env("redis_url", "OMNICLAW_REDIS_URL") + # Auto-detect nanopayments environment from OMNICLAW_ENV # production/prod/mainnet → mainnet, otherwise testnet is_production = env in {"prod", "production", "mainnet"} @@ -159,7 +162,7 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: confirm_always = ( overrides["confirm_always"] if "confirm_always" in overrides - else (_get_env_var("OMNICLAW_CONFIRM_ALWAYS", "false").lower() == "true") + else (str(_get_env_var("OMNICLAW_CONFIRM_ALWAYS", "false")).lower() == "true") ) confirm_threshold = override_or_env("confirm_threshold", "OMNICLAW_CONFIRM_THRESHOLD") @@ -168,7 +171,7 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: nanopayments_auto_topup = ( overrides.get("nanopayments_auto_topup") if "nanopayments_auto_topup" in overrides - else (_get_env_var("OMNICLAW_NANOPAYMENTS_AUTO_TOPUP", "true").lower() == "true") + else (str(_get_env_var("OMNICLAW_NANOPAYMENTS_AUTO_TOPUP", "true")).lower() == "true") ) nanopayments_topup_threshold = override_or_env( "nanopayments_topup_threshold", "OMNICLAW_NANOPAYMENTS_TOPUP_THRESHOLD", "1.00" @@ -188,13 +191,13 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: payment_strict_settlement = ( overrides.get("payment_strict_settlement") if "payment_strict_settlement" in overrides - else (_get_env_var("OMNICLAW_STRICT_SETTLEMENT", "true").lower() == "true") + else (str(_get_env_var("OMNICLAW_STRICT_SETTLEMENT", "true")).lower() == "true") ) auto_reconcile_pending_settlements = ( overrides.get("auto_reconcile_pending_settlements") if "auto_reconcile_pending_settlements" in overrides else ( - _get_env_var("OMNICLAW_AUTO_RECONCILE_PENDING_SETTLEMENTS", "false").lower() + str(_get_env_var("OMNICLAW_AUTO_RECONCILE_PENDING_SETTLEMENTS", "false")).lower() == "true" ) ) @@ -234,6 +237,8 @@ def override_or_env(name: str, env_name: str, default: Any = None) -> Any: nanopayments_default_network=nanopayments_default_network, payment_strict_settlement=payment_strict_settlement, auto_reconcile_pending_settlements=auto_reconcile_pending_settlements, + storage_backend=storage_backend, + redis_url=redis_url, ) def with_updates(self, **updates: Any) -> Config: @@ -269,6 +274,8 @@ def with_updates(self, **updates: Any) -> Config: "nanopayments_default_network": self.nanopayments_default_network, "payment_strict_settlement": self.payment_strict_settlement, "auto_reconcile_pending_settlements": self.auto_reconcile_pending_settlements, + "storage_backend": self.storage_backend, + "redis_url": self.redis_url, } current.update(updates) return Config(**current) 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 c731aec..7d1781f 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" @@ -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/seller/facilitator_generic.py b/src/omniclaw/seller/facilitator_generic.py index 95fd15f..4f5f0f9 100644 --- a/src/omniclaw/seller/facilitator_generic.py +++ b/src/omniclaw/seller/facilitator_generic.py @@ -584,7 +584,10 @@ def create_facilitator( """ import os - key = api_key or os.environ.get("FACILITATOR_API_KEY") or os.environ.get("CIRCLE_API_KEY") + if api_key is not None: + key = api_key + else: + key = os.environ.get("FACILITATOR_API_KEY") or os.environ.get("CIRCLE_API_KEY") if not key: raise ValueError("api_key is required") 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..0be6afa 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 @@ -531,13 +531,9 @@ def get_or_create_default_wallet_set( """ Get existing wallet set by name or create new one. - Args: - name: Wallet set name - - Returns: - Wallet set info + Circle V2 does not reliably return names in searches, so we use + idempotent creation via Circle SDK which handles name collisions. """ - # Circle API no longer returns wallet set names, so we always create a new set. return self.create_wallet_set(name) def setup_agent_wallet( @@ -557,13 +553,13 @@ 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, - blockchain=blockchain, + # 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.""" diff --git a/tests/conftest.py b/tests/conftest.py index 7073284..1dac053 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os import sys from unittest.mock import AsyncMock, MagicMock @@ -10,6 +11,17 @@ sys.modules["circle.web3.developer_controlled_wallets"] = MagicMock() +@pytest.fixture(autouse=True) +def force_test_env(monkeypatch): + """Ensure all tests use isolated environment to avoid .env leakage.""" + monkeypatch.setenv("OMNICLAW_STORAGE_BACKEND", "memory") + # Also set some safe defaults for other sensitive env vars + if not os.getenv("CIRCLE_API_KEY"): + monkeypatch.setenv("CIRCLE_API_KEY", "test_api_key_placeholder") + if not os.getenv("ENTITY_SECRET"): + monkeypatch.setenv("ENTITY_SECRET", "a" * 64) + + @pytest.fixture(autouse=True) def mock_circle_client(monkeypatch): """Automatically mock CircleClient for all tests to prevent network calls.""" diff --git a/tests/test_client.py b/tests/test_client.py index d0f428a..60b708d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -33,6 +33,7 @@ def mock_env(): { "CIRCLE_API_KEY": "test_api_key", "ENTITY_SECRET": "test_secret", + "OMNICLAW_STORAGE_BACKEND": "memory", }, ): yield diff --git a/tests/test_config.py b/tests/test_config.py index 509349b..f4d9cb9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -189,6 +189,6 @@ def test_default_timeouts(self) -> None: entity_secret="test_secret", ) - assert config.request_timeout == 30.0 + assert config.request_timeout == 60.0 assert config.transaction_poll_interval == 2.0 assert config.transaction_poll_timeout == 120.0 diff --git a/tests/test_external_qa.py b/tests/test_external_qa.py index 3e6a0f7..b2af58a 100644 --- a/tests/test_external_qa.py +++ b/tests/test_external_qa.py @@ -311,8 +311,9 @@ class TestExternalErrorHandling: def test_config_error_on_missing_key(self): """ConfigurationError when API key missing.""" # When no API key, Config.from_env raises ValueError - with pytest.raises(ValueError): - OmniClaw() + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError): + OmniClaw() def test_client_has_wallet_methods(self, external_client): """Client has wallet methods.""" diff --git a/tests/test_nanopayments_middleware.py b/tests/test_nanopayments_middleware.py index 479713b..b40487c 100644 --- a/tests/test_nanopayments_middleware.py +++ b/tests/test_nanopayments_middleware.py @@ -119,6 +119,7 @@ def _make_client() -> MagicMock: class TestGatewayMiddleware: """Tests for GatewayMiddleware 402 response structure.""" + @pytest.mark.asyncio async def test_402_body_has_correct_x402_version(self): middleware = GatewayMiddleware( seller_address="0x" + "a" * 40, @@ -138,7 +139,7 @@ async def test_402_body_has_correct_scheme(self): for accept in body["accepts"]: assert accept["scheme"] == "exact" - async def test_402_body_max_timeout_is_345600(self): + async def test_402_body_has_max_timeout(self): middleware = GatewayMiddleware( seller_address="0x" + "a" * 40, nanopayment_client=_make_client(), diff --git a/tests/test_setup.py b/tests/test_setup.py index 153d7f7..c799d03 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -149,7 +149,7 @@ def test_create_env_file_stores_managed_credentials(self) -> None: env_path = Path(tmpdir) / ".env" xdg_config_home = Path(tmpdir) / "xdg" - with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg_config_home)}, clear=False): + with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg_config_home)}, clear=True): create_env_file( api_key="TEST_API_KEY", entity_secret="a" * 64, @@ -162,7 +162,7 @@ def test_store_and_load_managed_credentials(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: xdg_config_home = Path(tmpdir) / "xdg" - with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg_config_home)}, clear=False): + with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg_config_home)}, clear=True): store_managed_credentials( "TEST_API_KEY", "b" * 64, @@ -181,7 +181,7 @@ def test_doctor_reports_managed_secret_source(self) -> None: "XDG_CONFIG_HOME": str(xdg_config_home), "CIRCLE_API_KEY": "TEST_API_KEY", }, - clear=False, + clear=True, ): store_managed_credentials( "TEST_API_KEY", @@ -199,7 +199,7 @@ def test_print_doctor_status_json(self, capsys) -> None: with patch.dict( os.environ, {"XDG_CONFIG_HOME": str(xdg_config_home), "CIRCLE_API_KEY": "TEST_API_KEY"}, - clear=False, + clear=True, ): print_doctor_status(as_json=True) output = capsys.readouterr().out diff --git a/tests/test_wallet_service.py b/tests/test_wallet_service.py index 10fb0f6..666dd7d 100644 --- a/tests/test_wallet_service.py +++ b/tests/test_wallet_service.py @@ -36,7 +36,10 @@ def mock_config() -> Config: @pytest.fixture def mock_circle_client() -> MagicMock: """Create a mock CircleClient.""" - return MagicMock() + mock = MagicMock() + # By default, don't find anything by name to force creation paths in tests + mock.find_wallet_set_by_name.return_value = None + return mock @pytest.fixture @@ -482,6 +485,7 @@ def test_get_or_create_default_wallet_set_existing( sample_wallet_set: WalletSetInfo, ) -> None: """Test get_or_create always creates new (Circle API no longer returns names).""" + mock_circle_client.find_wallet_set_by_name.return_value = sample_wallet_set mock_circle_client.create_wallet_set.return_value = sample_wallet_set result = wallet_service.get_or_create_default_wallet_set("Test Wallet Set")