diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 99a96fedce6..e86a9fc7e56 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3136,7 +3136,7 @@ def _coalesce_session_name_args(argv: list) -> list: "chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "status", "cron", "doctor", "config", "pairing", "skills", "tools", "mcp", "sessions", "insights", "version", "update", "uninstall", - "keystore", + "keystore", "wallet", } _SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"} @@ -4351,6 +4351,19 @@ For more help on a command: print("\n Keystore dependencies not installed.") print(" Install with: pip install 'hermes-agent[keystore]'\n") _ks_parser.set_defaults(func=_cmd_keystore_stub) + + # ========================================================================= + # wallet command + # ========================================================================= + try: + from wallet.cli import register_subparser as register_wallet + register_wallet(subparsers) + except ImportError: + _w_parser = subparsers.add_parser("wallet", help="Manage crypto wallets (requires wallet extras)") + def _cmd_wallet_stub(args): + print("\n Wallet dependencies not installed.") + print(" Install with: pip install 'hermes-agent[wallet]'\n") + _w_parser.set_defaults(func=_cmd_wallet_stub) # ========================================================================= # Parse and execute diff --git a/model_tools.py b/model_tools.py index c651d93ed73..cb3e8fe0e87 100644 --- a/model_tools.py +++ b/model_tools.py @@ -158,6 +158,7 @@ def _discover_tools(): "tools.send_message_tool", "tools.honcho_tools", "tools.homeassistant_tool", + "tools.wallet_tool", ] import importlib for mod_name in _modules: diff --git a/tests/wallet/__init__.py b/tests/wallet/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/wallet/test_manager.py b/tests/wallet/test_manager.py new file mode 100644 index 00000000000..e54fd0fe72d --- /dev/null +++ b/tests/wallet/test_manager.py @@ -0,0 +1,185 @@ +"""Tests for wallet.manager — wallet lifecycle and operations.""" + +import json +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest + +nacl = pytest.importorskip("nacl") +argon2 = pytest.importorskip("argon2") + +from keystore.client import KeystoreClient +from wallet.manager import WalletManager, WalletInfo, WalletNotFound, WalletError +from wallet.chains import ChainProvider, Balance, TransactionResult, GasEstimate, ChainConfig + + +class FakeProvider(ChainProvider): + """Mock chain provider for testing.""" + + def __init__(self): + super().__init__(ChainConfig( + chain_id="test-chain", + display_name="Test Chain", + symbol="TEST", + decimals=18, + rpc_url="http://localhost:8545", + explorer_url="https://testscan.io", + is_testnet=True, + )) + self._balances = {} + self._next_tx_hash = "0xabcdef1234567890" + + def get_balance(self, address: str) -> Balance: + bal = self._balances.get(address, Decimal("1.5")) + return Balance( + chain="test-chain", address=address, + balance=bal, balance_raw=int(bal * 10**18), + symbol="TEST", decimals=18, + ) + + def send_transaction(self, from_private_key, to_address, amount) -> TransactionResult: + return TransactionResult( + tx_hash=self._next_tx_hash, + chain="test-chain", + status="submitted", + explorer_url=f"https://testscan.io/tx/{self._next_tx_hash}", + ) + + def estimate_fee(self, from_address, to_address, amount) -> GasEstimate: + return GasEstimate( + chain="test-chain", + estimated_fee=Decimal("0.001"), + estimated_fee_raw=1000000000000000, + symbol="TEST", + ) + + def validate_address(self, address: str) -> bool: + return address.startswith("0x") and len(address) == 42 + + def generate_keypair(self): + return ("0x" + "A" * 40, "deadbeef" * 8) + + @staticmethod + def address_from_key(private_key: str) -> str: + return "0x" + "B" * 40 + + +@pytest.fixture +def ks(tmp_path): + """Initialized and unlocked keystore.""" + db = tmp_path / "keystore" / "secrets.db" + client = KeystoreClient(db) + client.initialize("test-pass") + return client + + +@pytest.fixture +def mgr(ks): + """Wallet manager with a fake test chain provider.""" + m = WalletManager(ks) + m.register_provider("test-chain", FakeProvider()) + return m + + +class TestWalletCreation: + def test_create_wallet(self, mgr): + w = mgr.create_wallet(chain="test-chain", label="My Test Wallet") + assert w.label == "My Test Wallet" + assert w.chain == "test-chain" + assert w.address.startswith("0x") + assert w.wallet_type == "user" + + def test_create_agent_wallet(self, mgr): + w = mgr.create_wallet(chain="test-chain", wallet_type="agent") + assert w.wallet_type == "agent" + + def test_create_unsupported_chain(self, mgr): + with pytest.raises(WalletError, match="No provider"): + mgr.create_wallet(chain="nonexistent") + + def test_list_wallets(self, mgr): + mgr.create_wallet(chain="test-chain", label="W1") + mgr.create_wallet(chain="test-chain", label="W2") + wallets = mgr.list_wallets() + assert len(wallets) == 2 + labels = {w.label for w in wallets} + assert "W1" in labels + assert "W2" in labels + + def test_get_wallet(self, mgr): + w = mgr.create_wallet(chain="test-chain", label="Find Me") + found = mgr.get_wallet(w.wallet_id) + assert found.label == "Find Me" + assert found.address == w.address + + def test_get_wallet_not_found(self, mgr): + with pytest.raises(WalletNotFound): + mgr.get_wallet("w_nonexistent") + + def test_delete_wallet(self, mgr): + w = mgr.create_wallet(chain="test-chain") + assert mgr.delete_wallet(w.wallet_id) + assert len(mgr.list_wallets()) == 0 + + +class TestImport: + def test_import_wallet(self, mgr): + w = mgr.import_wallet( + chain="test-chain", + private_key="deadbeef" * 8, + label="Imported", + ) + assert w.label == "Imported" + assert w.address == "0x" + "B" * 40 # from FakeProvider.address_from_key + + +class TestBalance: + def test_get_balance(self, mgr): + w = mgr.create_wallet(chain="test-chain") + bal = mgr.get_balance(w.wallet_id) + assert bal.symbol == "TEST" + assert bal.balance == Decimal("1.5") + + +class TestSend: + def test_send_success(self, mgr): + w = mgr.create_wallet(chain="test-chain") + result = mgr.send(w.wallet_id, "0x" + "C" * 40, Decimal("0.1")) + assert result.status == "submitted" + assert result.tx_hash == "0xabcdef1234567890" + + def test_send_invalid_address(self, mgr): + w = mgr.create_wallet(chain="test-chain") + result = mgr.send(w.wallet_id, "invalid", Decimal("0.1")) + assert result.status == "failed" + assert "Invalid address" in result.error + + def test_tx_history(self, mgr): + w = mgr.create_wallet(chain="test-chain") + mgr.send(w.wallet_id, "0x" + "C" * 40, Decimal("0.1")) + mgr.send(w.wallet_id, "0x" + "D" * 40, Decimal("0.2")) + history = mgr.get_tx_history() + assert len(history) == 2 + + +class TestResolve: + def test_resolve_single_wallet(self, mgr): + w = mgr.create_wallet(chain="test-chain") + resolved = mgr.resolve_wallet() + assert resolved.wallet_id == w.wallet_id + + def test_resolve_by_chain(self, mgr): + mgr.create_wallet(chain="test-chain", label="A") + resolved = mgr.resolve_wallet(chain="test-chain") + assert resolved.label == "A" + + def test_resolve_no_wallets(self, mgr): + with pytest.raises(WalletNotFound, match="No wallets"): + mgr.resolve_wallet() + + def test_resolve_ambiguous(self, mgr): + mgr.create_wallet(chain="test-chain", label="A") + mgr.create_wallet(chain="test-chain", label="B") + with pytest.raises(WalletError, match="Multiple"): + mgr.resolve_wallet() diff --git a/tests/wallet/test_policy.py b/tests/wallet/test_policy.py new file mode 100644 index 00000000000..fcb70761340 --- /dev/null +++ b/tests/wallet/test_policy.py @@ -0,0 +1,150 @@ +"""Tests for wallet.policy — transaction policy engine.""" + +import time +from decimal import Decimal + +import pytest + +from wallet.policy import ( + PolicyEngine, + PolicyResult, + PolicyVerdict, + TxRequest, + AGENT_WALLET_DEFAULTS, +) + + +def _make_tx(**overrides) -> TxRequest: + defaults = { + "wallet_id": "w_test", + "wallet_type": "agent", + "chain": "ethereum-sepolia", + "to_address": "0x1234567890abcdef1234567890abcdef12345678", + "amount": Decimal("0.01"), + "symbol": "ETH", + } + defaults.update(overrides) + return TxRequest(**defaults) + + +class TestBasicPolicy: + def test_allow_small_agent_tx(self): + engine = PolicyEngine() + tx = _make_tx(amount=Decimal("0.001")) + result = engine.evaluate(tx) + assert result.verdict == PolicyVerdict.ALLOW + + def test_block_over_spending_limit(self): + engine = PolicyEngine({"spending_limit": {"max_native": "0.5"}}) + tx = _make_tx(amount=Decimal("1.0")) + result = engine.evaluate(tx) + assert result.verdict == PolicyVerdict.BLOCK + assert "spending_limit" in result.failed + + def test_require_approval_above_threshold(self): + engine = PolicyEngine({"require_approval": {"above_native": "0.1"}}) + tx = _make_tx(amount=Decimal("0.5"), wallet_type="agent") + result = engine.evaluate(tx) + assert result.verdict == PolicyVerdict.REQUIRE_APPROVAL + + def test_user_wallet_always_requires_approval(self): + engine = PolicyEngine() + tx = _make_tx(wallet_type="user", amount=Decimal("0.001")) + result = engine.evaluate(tx) + # User wallet defaults require approval for any amount + assert result.verdict == PolicyVerdict.REQUIRE_APPROVAL + + +class TestRateLimit: + def test_rate_limit_blocks_after_max(self): + engine = PolicyEngine({"rate_limit": {"max_txns": 2, "window_seconds": 3600}}) + tx = _make_tx() + + # First two should pass + r1 = engine.evaluate(tx) + engine.record_transaction(tx) + r2 = engine.evaluate(tx) + engine.record_transaction(tx) + + # Third should be blocked + r3 = engine.evaluate(tx) + assert r3.verdict == PolicyVerdict.BLOCK + assert "rate_limit" in r3.failed + + +class TestCooldown: + def test_cooldown_blocks_rapid_txs(self): + engine = PolicyEngine({"cooldown": {"min_seconds": 60}}) + tx = _make_tx() + + # First is fine + engine.record_transaction(tx) + + # Immediate second should be blocked + result = engine.evaluate(tx) + assert result.verdict == PolicyVerdict.BLOCK + assert "cooldown" in result.failed + + +class TestRecipientPolicies: + def test_allowed_recipients_blocks_unknown(self): + engine = PolicyEngine({ + "allowed_recipients": {"addresses": ["0xAAAA"]}, + }) + tx = _make_tx(to_address="0xBBBB") + result = engine.evaluate(tx) + assert result.verdict == PolicyVerdict.BLOCK + + def test_allowed_recipients_passes_known(self): + addr = "0x1234567890abcdef1234567890abcdef12345678" + engine = PolicyEngine({ + "allowed_recipients": {"addresses": [addr]}, + }) + tx = _make_tx(to_address=addr) + # Agent defaults may still require approval, but shouldn't be BLOCKED + result = engine.evaluate(tx) + assert result.verdict != PolicyVerdict.BLOCK or result.failed != "allowed_recipients" + + def test_blocked_recipients(self): + bad = "0xBADBADBADBADBADBADBADBADBADBADBADBADBADBA" + engine = PolicyEngine({ + "blocked_recipients": {"addresses": [bad]}, + }) + tx = _make_tx(to_address=bad) + result = engine.evaluate(tx) + assert result.verdict == PolicyVerdict.BLOCK + assert "blocked_recipients" in result.failed + + +class TestFreezeKillSwitch: + def test_freeze_blocks_everything(self): + engine = PolicyEngine() + engine.freeze() + tx = _make_tx(amount=Decimal("0.0001")) + result = engine.evaluate(tx) + assert result.verdict == PolicyVerdict.BLOCK + assert "frozen" in result.reason.lower() + + def test_unfreeze_resumes(self): + engine = PolicyEngine() + engine.freeze() + engine.unfreeze() + tx = _make_tx(amount=Decimal("0.0001")) + result = engine.evaluate(tx) + assert result.verdict != PolicyVerdict.BLOCK or result.failed != "freeze" + + +class TestDailyLimit: + def test_daily_limit_blocks_aggregate(self): + engine = PolicyEngine({"daily_limit": {"max_native": "0.05"}}) + tx = _make_tx(amount=Decimal("0.03")) + + # First tx: 0.03 of 0.05 limit + r1 = engine.evaluate(tx) + assert r1.verdict != PolicyVerdict.BLOCK or r1.failed != "daily_limit" + engine.record_transaction(tx) + + # Second tx: 0.03 more would be 0.06 > 0.05 + r2 = engine.evaluate(tx) + assert r2.verdict == PolicyVerdict.BLOCK + assert "daily_limit" in r2.failed diff --git a/tools/wallet_tool.py b/tools/wallet_tool.py new file mode 100644 index 00000000000..902d98c6d7d --- /dev/null +++ b/tools/wallet_tool.py @@ -0,0 +1,423 @@ +"""Agent-facing wallet tools. + +These are the tools the LLM can call. They go through the wallet manager +and policy engine — the agent never has access to private keys. + +All handlers return JSON strings per Hermes convention. +""" + +import json +import logging +from decimal import Decimal, InvalidOperation +from typing import Optional + +from tools.registry import registry + +logger = logging.getLogger(__name__) + +# Lazy-loaded singleton (initialized on first tool call) +_wallet_manager = None +_policy_engine = None + + +def _get_manager(): + """Lazy-init the wallet manager + policy engine.""" + global _wallet_manager, _policy_engine + if _wallet_manager is not None: + return _wallet_manager, _policy_engine + + try: + from keystore.client import get_keystore + from wallet.manager import WalletManager + from wallet.policy import PolicyEngine + + ks = get_keystore() + if not ks.is_unlocked: + return None, None + + _wallet_manager = WalletManager(ks) + _policy_engine = PolicyEngine() + + # Register available chain providers + _register_providers(_wallet_manager) + + return _wallet_manager, _policy_engine + except ImportError: + return None, None + except Exception as e: + logger.debug("Wallet manager init failed: %s", e) + return None, None + + +def _register_providers(mgr): + """Register chain providers based on installed deps.""" + try: + from wallet.chains.evm import EVMProvider, EVM_CHAINS + for chain_id, config in EVM_CHAINS.items(): + mgr.register_provider(chain_id, EVMProvider(config)) + except ImportError: + pass + + try: + from wallet.chains.solana import SolanaProvider, SOLANA_CHAINS + for chain_id, config in SOLANA_CHAINS.items(): + mgr.register_provider(chain_id, SolanaProvider(config)) + except ImportError: + pass + + +def _check_wallet_available() -> bool: + """Check if wallet functionality is available.""" + mgr, _ = _get_manager() + return mgr is not None + + +# ========================================================================= +# Tool handlers +# ========================================================================= + +def wallet_list(task_id: str = None, **kw) -> str: + """List all wallets with their addresses and balances.""" + mgr, _ = _get_manager() + if mgr is None: + return json.dumps({"error": "Wallet not available. Run 'hermes wallet create' first."}) + + wallets = mgr.list_wallets() + if not wallets: + return json.dumps({ + "wallets": [], + "message": "No wallets found. Create one with 'hermes wallet create'.", + }) + + result = [] + for w in wallets: + entry = { + "wallet_id": w.wallet_id, + "label": w.label, + "chain": w.chain, + "address": w.address, + "type": w.wallet_type, + } + # Try to fetch balance (non-blocking, skip on error) + try: + bal = mgr.get_balance(w.wallet_id) + entry["balance"] = str(bal.balance) + entry["symbol"] = bal.symbol + except Exception: + entry["balance"] = "unavailable" + entry["symbol"] = "" + result.append(entry) + + return json.dumps({"wallets": result}) + + +def wallet_balance(args: dict, task_id: str = None, **kw) -> str: + """Check wallet balance.""" + mgr, _ = _get_manager() + if mgr is None: + return json.dumps({"error": "Wallet not available"}) + + wallet_id = args.get("wallet_id") + chain = args.get("chain") + + try: + wallet = mgr.resolve_wallet(wallet_id=wallet_id, chain=chain) + bal = mgr.get_balance(wallet.wallet_id) + return json.dumps({ + "wallet": wallet.label, + "address": wallet.address, + "chain": wallet.chain, + "balance": str(bal.balance), + "symbol": bal.symbol, + }) + except Exception as e: + return json.dumps({"error": str(e)}) + + +def wallet_send(args: dict, task_id: str = None, **kw) -> str: + """Request a token transfer. Subject to policy engine approval.""" + mgr, policy = _get_manager() + if mgr is None: + return json.dumps({"error": "Wallet not available"}) + + to_address = args.get("to", "") + amount_str = args.get("amount", "") + wallet_id = args.get("wallet_id") + chain = args.get("chain") + + if not to_address or not amount_str: + return json.dumps({"error": "Both 'to' and 'amount' are required"}) + + try: + amount = Decimal(amount_str) + except InvalidOperation: + return json.dumps({"error": f"Invalid amount: {amount_str}"}) + + if amount <= 0: + return json.dumps({"error": "Amount must be positive"}) + + try: + wallet = mgr.resolve_wallet(wallet_id=wallet_id, chain=chain) + except Exception as e: + return json.dumps({"error": str(e)}) + + # Get chain symbol + try: + provider = mgr.get_provider(wallet.chain) + symbol = provider.config.symbol + except Exception: + symbol = "?" + + # Evaluate policy + from wallet.policy import TxRequest, PolicyVerdict + tx_req = TxRequest( + wallet_id=wallet.wallet_id, + wallet_type=wallet.wallet_type, + chain=wallet.chain, + to_address=to_address, + amount=amount, + symbol=symbol, + ) + + if policy: + result = policy.evaluate(tx_req) + + if result.verdict == PolicyVerdict.BLOCK: + return json.dumps({ + "status": "blocked", + "reason": result.reason, + "policy": result.failed, + }) + + if result.verdict == PolicyVerdict.REQUIRE_APPROVAL: + # For v1: return pending status — the CLI/gateway approval flow + # will handle the user interaction + return json.dumps({ + "status": "pending_approval", + "reason": result.reason, + "transaction": { + "from": wallet.address, + "to": to_address, + "amount": str(amount), + "symbol": symbol, + "chain": wallet.chain, + "wallet": wallet.label, + }, + "message": ( + f"Transaction requires owner approval: send {amount} {symbol} " + f"to {to_address} on {wallet.chain}. " + "The owner will be prompted to approve or deny." + ), + }) + + # Policy passed — execute + try: + tx_result = mgr.send(wallet.wallet_id, to_address, amount, decided_by="policy_auto") + if policy: + policy.record_transaction(tx_req) + + if tx_result.status == "failed": + return json.dumps({ + "status": "failed", + "error": tx_result.error, + }) + + return json.dumps({ + "status": "submitted", + "tx_hash": tx_result.tx_hash, + "explorer_url": tx_result.explorer_url, + "chain": tx_result.chain, + "from": wallet.address, + "to": to_address, + "amount": str(amount), + "symbol": symbol, + }) + except Exception as e: + return json.dumps({"error": f"Transaction failed: {e}"}) + + +def wallet_history(args: dict, task_id: str = None, **kw) -> str: + """Get transaction history.""" + mgr, _ = _get_manager() + if mgr is None: + return json.dumps({"error": "Wallet not available"}) + + wallet_id = args.get("wallet_id") + limit = args.get("limit", 20) + + records = mgr.get_tx_history(wallet_id=wallet_id, limit=limit) + if not records: + return json.dumps({"transactions": [], "message": "No transaction history"}) + + return json.dumps({ + "transactions": [ + { + "tx_id": r.tx_id, + "chain": r.chain, + "to": r.to_address, + "amount": r.amount, + "symbol": r.symbol, + "tx_hash": r.tx_hash, + "status": r.status, + "time": r.requested_at, + } + for r in records + ], + }) + + +def wallet_estimate_gas(args: dict, task_id: str = None, **kw) -> str: + """Estimate transaction fee.""" + mgr, _ = _get_manager() + if mgr is None: + return json.dumps({"error": "Wallet not available"}) + + to_address = args.get("to", "") + amount_str = args.get("amount", "0.01") + wallet_id = args.get("wallet_id") + chain = args.get("chain") + + try: + amount = Decimal(amount_str) + wallet = mgr.resolve_wallet(wallet_id=wallet_id, chain=chain) + estimate = mgr.estimate_fee(wallet.wallet_id, to_address, amount) + return json.dumps({ + "chain": estimate.chain, + "estimated_fee": str(estimate.estimated_fee), + "symbol": estimate.symbol, + }) + except Exception as e: + return json.dumps({"error": str(e)}) + + +# ========================================================================= +# Tool registration +# ========================================================================= + +registry.register( + name="wallet_list", + toolset="wallet", + schema={ + "name": "wallet_list", + "description": ( + "List all crypto wallets with their addresses and balances. " + "Shows wallet ID, label, chain, address, and current balance." + ), + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + handler=lambda args, **kw: wallet_list(**kw), + check_fn=_check_wallet_available, + emoji="💰", +) + +registry.register( + name="wallet_balance", + toolset="wallet", + schema={ + "name": "wallet_balance", + "description": "Check the native token balance of a crypto wallet.", + "parameters": { + "type": "object", + "properties": { + "wallet_id": { + "type": "string", + "description": "Wallet ID (optional — uses default if only one wallet exists)", + }, + "chain": { + "type": "string", + "description": "Chain name (e.g. 'ethereum', 'solana', 'base')", + }, + }, + "required": [], + }, + }, + handler=lambda args, **kw: wallet_balance(args, **kw), + check_fn=_check_wallet_available, + emoji="💰", +) + +registry.register( + name="wallet_send", + toolset="wallet", + schema={ + "name": "wallet_send", + "description": ( + "Send native tokens (ETH, SOL, etc.) to an address. " + "Subject to spending limits and may require owner approval for large amounts. " + "Returns transaction hash on success or pending_approval status." + ), + "parameters": { + "type": "object", + "properties": { + "to": { + "type": "string", + "description": "Recipient wallet address", + }, + "amount": { + "type": "string", + "description": "Amount to send in native token units (e.g. '0.01' for 0.01 ETH)", + }, + "wallet_id": { + "type": "string", + "description": "Wallet ID to send from (optional — uses default if only one)", + }, + "chain": { + "type": "string", + "description": "Chain name (optional if wallet_id is provided)", + }, + }, + "required": ["to", "amount"], + }, + }, + handler=lambda args, **kw: wallet_send(args, **kw), + check_fn=_check_wallet_available, + emoji="📤", +) + +registry.register( + name="wallet_history", + toolset="wallet", + schema={ + "name": "wallet_history", + "description": "Get recent transaction history for a wallet.", + "parameters": { + "type": "object", + "properties": { + "wallet_id": { + "type": "string", + "description": "Wallet ID (optional — shows all if omitted)", + }, + "limit": { + "type": "integer", + "description": "Maximum number of transactions to return (default: 20)", + }, + }, + "required": [], + }, + }, + handler=lambda args, **kw: wallet_history(args, **kw), + check_fn=_check_wallet_available, + emoji="📋", +) + +registry.register( + name="wallet_estimate_gas", + toolset="wallet", + schema={ + "name": "wallet_estimate_gas", + "description": "Estimate the transaction fee for sending tokens.", + "parameters": { + "type": "object", + "properties": { + "to": {"type": "string", "description": "Recipient address"}, + "amount": {"type": "string", "description": "Amount to send"}, + "wallet_id": {"type": "string", "description": "Wallet ID"}, + "chain": {"type": "string", "description": "Chain name"}, + }, + "required": ["to"], + }, + }, + handler=lambda args, **kw: wallet_estimate_gas(args, **kw), + check_fn=_check_wallet_available, + emoji="⛽", +) diff --git a/toolsets.py b/toolsets.py index e1e780ef39a..bc1d6cbd244 100644 --- a/toolsets.py +++ b/toolsets.py @@ -208,6 +208,12 @@ TOOLSETS = { "includes": [] }, + "wallet": { + "description": "Cryptocurrency wallet — check balances, send tokens, view history", + "tools": ["wallet_list", "wallet_balance", "wallet_send", "wallet_history", "wallet_estimate_gas"], + "includes": [] + }, + # Scenario-specific toolsets diff --git a/wallet/__init__.py b/wallet/__init__.py new file mode 100644 index 00000000000..dbc37e7be83 --- /dev/null +++ b/wallet/__init__.py @@ -0,0 +1,20 @@ +"""hermes-wallet — crypto wallet for Hermes Agent. + +Built on top of hermes-keystore. Private keys are stored as sealed +secrets — the agent never has direct access. Transactions go through +a policy engine and optional owner approval. + +Scope (v1): + - Wallet creation, import, listing + - Native token transfers (ETH, SOL) + - Balance checks + - Transaction history (local log) + - Policy engine (spending limits, rate limits, approval thresholds) + - CLI + gateway approval flow for high-value transactions + +Out of scope (v1): + - Smart contracts / DeFi / swaps + - ERC-20 / SPL token transfers + - Hardware wallets + - Multi-sig +""" diff --git a/wallet/chains/__init__.py b/wallet/chains/__init__.py new file mode 100644 index 00000000000..5aa1e40c755 --- /dev/null +++ b/wallet/chains/__init__.py @@ -0,0 +1,102 @@ +"""Abstract chain provider interface. + +Each blockchain (EVM, Solana, etc.) implements this interface. +The wallet manager dispatches through it without knowing chain specifics. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + + +@dataclass +class Balance: + """Native token balance for a wallet.""" + chain: str + address: str + balance: Decimal # In native units (ETH, SOL) + balance_raw: int # In smallest unit (wei, lamports) + symbol: str # "ETH", "SOL" + decimals: int # 18 for ETH, 9 for SOL + + +@dataclass +class TransactionResult: + """Result of a submitted transaction.""" + tx_hash: str + chain: str + status: str # "submitted" | "confirmed" | "failed" + explorer_url: str = "" + gas_used: Optional[int] = None + error: Optional[str] = None + + +@dataclass +class GasEstimate: + """Gas/fee estimate for a transaction.""" + chain: str + estimated_fee: Decimal # In native units + estimated_fee_raw: int # In smallest unit + symbol: str + + +@dataclass +class ChainConfig: + """Configuration for a blockchain.""" + chain_id: str # "ethereum", "base", "solana", etc. + display_name: str + symbol: str # Native token symbol + decimals: int + rpc_url: str + explorer_url: str # Base URL for tx explorer + is_testnet: bool = False + + +class ChainProvider(ABC): + """Abstract interface for blockchain interaction.""" + + def __init__(self, config: ChainConfig): + self.config = config + + @property + def chain_id(self) -> str: + return self.config.chain_id + + @abstractmethod + def get_balance(self, address: str) -> Balance: + """Get native token balance for an address.""" + ... + + @abstractmethod + def send_transaction( + self, + from_private_key: str, + to_address: str, + amount: Decimal, + ) -> TransactionResult: + """Sign and broadcast a native token transfer. + + Args: + from_private_key: Hex-encoded private key (provided by daemon, never by agent) + to_address: Recipient address + amount: Amount in native units (ETH, SOL) + + Returns: + TransactionResult with tx hash and status + """ + ... + + @abstractmethod + def estimate_fee(self, from_address: str, to_address: str, amount: Decimal) -> GasEstimate: + """Estimate transaction fee.""" + ... + + @abstractmethod + def validate_address(self, address: str) -> bool: + """Check if an address is valid for this chain.""" + ... + + def explorer_tx_url(self, tx_hash: str) -> str: + """Return the block explorer URL for a transaction.""" + return f"{self.config.explorer_url}/tx/{tx_hash}" diff --git a/wallet/chains/evm.py b/wallet/chains/evm.py new file mode 100644 index 00000000000..9a0209ba939 --- /dev/null +++ b/wallet/chains/evm.py @@ -0,0 +1,241 @@ +"""EVM chain provider — Ethereum, Base, Polygon, Arbitrum, etc. + +Uses eth-account for key management/signing and web3.py for RPC. +All EVM chains share this single provider with different ChainConfig. +""" + +import logging +from decimal import Decimal +from typing import Optional + +from wallet.chains import ( + Balance, + ChainConfig, + ChainProvider, + GasEstimate, + TransactionResult, +) + +logger = logging.getLogger(__name__) + +try: + from eth_account import Account + from web3 import Web3 + _WEB3_AVAILABLE = True +except ImportError: + _WEB3_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Pre-built chain configs +# --------------------------------------------------------------------------- + +EVM_CHAINS = { + "ethereum": ChainConfig( + chain_id="ethereum", + display_name="Ethereum Mainnet", + symbol="ETH", + decimals=18, + rpc_url="https://eth.llamarpc.com", + explorer_url="https://etherscan.io", + ), + "ethereum-sepolia": ChainConfig( + chain_id="ethereum-sepolia", + display_name="Ethereum Sepolia (Testnet)", + symbol="ETH", + decimals=18, + rpc_url="https://rpc.sepolia.org", + explorer_url="https://sepolia.etherscan.io", + is_testnet=True, + ), + "base": ChainConfig( + chain_id="base", + display_name="Base", + symbol="ETH", + decimals=18, + rpc_url="https://mainnet.base.org", + explorer_url="https://basescan.org", + ), + "base-sepolia": ChainConfig( + chain_id="base-sepolia", + display_name="Base Sepolia (Testnet)", + symbol="ETH", + decimals=18, + rpc_url="https://sepolia.base.org", + explorer_url="https://sepolia.basescan.org", + is_testnet=True, + ), + "polygon": ChainConfig( + chain_id="polygon", + display_name="Polygon", + symbol="POL", + decimals=18, + rpc_url="https://polygon-rpc.com", + explorer_url="https://polygonscan.com", + ), + "arbitrum": ChainConfig( + chain_id="arbitrum", + display_name="Arbitrum One", + symbol="ETH", + decimals=18, + rpc_url="https://arb1.arbitrum.io/rpc", + explorer_url="https://arbiscan.io", + ), + "optimism": ChainConfig( + chain_id="optimism", + display_name="Optimism", + symbol="ETH", + decimals=18, + rpc_url="https://mainnet.optimism.io", + explorer_url="https://optimistic.etherscan.io", + ), +} + + +# EVM chain IDs (for transaction signing) +_CHAIN_IDS = { + "ethereum": 1, + "ethereum-sepolia": 11155111, + "base": 8453, + "base-sepolia": 84532, + "polygon": 137, + "arbitrum": 42161, + "optimism": 10, +} + + +class EVMProvider(ChainProvider): + """Provider for all EVM-compatible chains.""" + + def __init__(self, config: ChainConfig, rpc_url_override: str = ""): + if not _WEB3_AVAILABLE: + raise ImportError( + "web3 and eth-account are required for EVM wallet support. " + "Install with: pip install 'hermes-agent[wallet]'" + ) + super().__init__(config) + url = rpc_url_override or config.rpc_url + self._w3 = Web3(Web3.HTTPProvider(url)) + self._evm_chain_id = _CHAIN_IDS.get(config.chain_id) + + def get_balance(self, address: str) -> Balance: + checksum = Web3.to_checksum_address(address) + balance_wei = self._w3.eth.get_balance(checksum) + balance_eth = Decimal(balance_wei) / Decimal(10 ** self.config.decimals) + return Balance( + chain=self.config.chain_id, + address=address, + balance=balance_eth, + balance_raw=balance_wei, + symbol=self.config.symbol, + decimals=self.config.decimals, + ) + + def send_transaction( + self, + from_private_key: str, + to_address: str, + amount: Decimal, + ) -> TransactionResult: + account = Account.from_key(from_private_key) + to_checksum = Web3.to_checksum_address(to_address) + amount_wei = int(amount * Decimal(10 ** self.config.decimals)) + + try: + nonce = self._w3.eth.get_transaction_count(account.address) + + # Build transaction + tx = { + "to": to_checksum, + "value": amount_wei, + "nonce": nonce, + "chainId": self._evm_chain_id, + } + + # Use EIP-1559 if supported, otherwise legacy + try: + latest = self._w3.eth.get_block("latest") + if hasattr(latest, "baseFeePerGas") and latest.baseFeePerGas is not None: + # EIP-1559 + max_priority = self._w3.eth.max_priority_fee + base_fee = latest.baseFeePerGas + tx["maxFeePerGas"] = base_fee * 2 + max_priority + tx["maxPriorityFeePerGas"] = max_priority + else: + tx["gasPrice"] = self._w3.eth.gas_price + except Exception: + tx["gasPrice"] = self._w3.eth.gas_price + + # Estimate gas + tx["gas"] = self._w3.eth.estimate_gas(tx) + + # Sign and send + signed = self._w3.eth.account.sign_transaction(tx, from_private_key) + tx_hash = self._w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hash_hex = tx_hash.hex() + + logger.info("Transaction sent: %s on %s", tx_hash_hex, self.config.chain_id) + + return TransactionResult( + tx_hash=tx_hash_hex, + chain=self.config.chain_id, + status="submitted", + explorer_url=self.explorer_tx_url(tx_hash_hex), + ) + except Exception as e: + logger.error("Transaction failed on %s: %s", self.config.chain_id, e) + return TransactionResult( + tx_hash="", + chain=self.config.chain_id, + status="failed", + error=str(e), + ) + + def estimate_fee(self, from_address: str, to_address: str, amount: Decimal) -> GasEstimate: + from_checksum = Web3.to_checksum_address(from_address) + to_checksum = Web3.to_checksum_address(to_address) + amount_wei = int(amount * Decimal(10 ** self.config.decimals)) + + try: + gas_limit = self._w3.eth.estimate_gas({ + "from": from_checksum, + "to": to_checksum, + "value": amount_wei, + }) + gas_price = self._w3.eth.gas_price + fee_wei = gas_limit * gas_price + fee_eth = Decimal(fee_wei) / Decimal(10 ** self.config.decimals) + + return GasEstimate( + chain=self.config.chain_id, + estimated_fee=fee_eth, + estimated_fee_raw=fee_wei, + symbol=self.config.symbol, + ) + except Exception as e: + # Return a rough estimate on failure + rough_fee = Decimal("0.0005") # ~21000 gas * ~24 gwei + return GasEstimate( + chain=self.config.chain_id, + estimated_fee=rough_fee, + estimated_fee_raw=int(rough_fee * Decimal(10 ** 18)), + symbol=self.config.symbol, + ) + + def validate_address(self, address: str) -> bool: + try: + Web3.to_checksum_address(address) + return True + except (ValueError, Exception): + return False + + def generate_keypair(self) -> tuple[str, str]: + """Generate a new EVM keypair. Returns (address, private_key_hex).""" + account = Account.create() + return account.address, account.key.hex() + + @staticmethod + def address_from_key(private_key: str) -> str: + """Derive address from a private key.""" + account = Account.from_key(private_key) + return account.address diff --git a/wallet/chains/solana.py b/wallet/chains/solana.py new file mode 100644 index 00000000000..8c2474e28a0 --- /dev/null +++ b/wallet/chains/solana.py @@ -0,0 +1,177 @@ +"""Solana chain provider. + +Uses solders for key management/signing and solana-py for RPC. +""" + +import logging +from decimal import Decimal +from typing import Optional + +from wallet.chains import ( + Balance, + ChainConfig, + ChainProvider, + GasEstimate, + TransactionResult, +) + +logger = logging.getLogger(__name__) + +try: + from solders.keypair import Keypair + from solders.pubkey import Pubkey + from solders.system_program import transfer, TransferParams + from solders.transaction import Transaction + from solders.message import Message + from solders.hash import Hash as SolHash + from solana.rpc.api import Client as SolanaClient + from solana.rpc.commitment import Confirmed + _SOLANA_AVAILABLE = True +except ImportError: + _SOLANA_AVAILABLE = False + + +SOLANA_CHAINS = { + "solana": ChainConfig( + chain_id="solana", + display_name="Solana Mainnet", + symbol="SOL", + decimals=9, + rpc_url="https://api.mainnet-beta.solana.com", + explorer_url="https://explorer.solana.com", + ), + "solana-devnet": ChainConfig( + chain_id="solana-devnet", + display_name="Solana Devnet (Testnet)", + symbol="SOL", + decimals=9, + rpc_url="https://api.devnet.solana.com", + explorer_url="https://explorer.solana.com", + is_testnet=True, + ), +} + +_LAMPORTS_PER_SOL = 1_000_000_000 + + +class SolanaProvider(ChainProvider): + """Provider for Solana.""" + + def __init__(self, config: ChainConfig, rpc_url_override: str = ""): + if not _SOLANA_AVAILABLE: + raise ImportError( + "solders and solana are required for Solana wallet support. " + "Install with: pip install 'hermes-agent[wallet-solana]'" + ) + super().__init__(config) + url = rpc_url_override or config.rpc_url + self._client = SolanaClient(url) + + def get_balance(self, address: str) -> Balance: + pubkey = Pubkey.from_string(address) + resp = self._client.get_balance(pubkey, commitment=Confirmed) + lamports = resp.value + sol = Decimal(lamports) / Decimal(_LAMPORTS_PER_SOL) + return Balance( + chain=self.config.chain_id, + address=address, + balance=sol, + balance_raw=lamports, + symbol="SOL", + decimals=9, + ) + + def send_transaction( + self, + from_private_key: str, + to_address: str, + amount: Decimal, + ) -> TransactionResult: + try: + # Parse keypair — stored as hex-encoded 64-byte keypair (secret + public) + key_bytes = bytes.fromhex(from_private_key) + keypair = Keypair.from_bytes(key_bytes) + to_pubkey = Pubkey.from_string(to_address) + lamports = int(amount * Decimal(_LAMPORTS_PER_SOL)) + + # Get recent blockhash — use Finalized for reliability on devnet + from solana.rpc.commitment import Finalized + blockhash_resp = self._client.get_latest_blockhash(commitment=Finalized) + recent_blockhash = blockhash_resp.value.blockhash + + # Build transfer instruction + ix = transfer(TransferParams( + from_pubkey=keypair.pubkey(), + to_pubkey=to_pubkey, + lamports=lamports, + )) + + # Build and sign transaction + msg = Message.new_with_blockhash([ix], keypair.pubkey(), recent_blockhash) + tx = Transaction.new_unsigned(msg) + tx.sign([keypair], recent_blockhash) + + # Send + resp = self._client.send_transaction(tx) + tx_hash = str(resp.value) + + cluster_param = "" + if self.config.is_testnet: + cluster_param = "?cluster=devnet" + + logger.info("Solana transaction sent: %s", tx_hash) + return TransactionResult( + tx_hash=tx_hash, + chain=self.config.chain_id, + status="submitted", + explorer_url=f"{self.config.explorer_url}/tx/{tx_hash}{cluster_param}", + ) + except Exception as e: + logger.error("Solana transaction failed: %s", e) + return TransactionResult( + tx_hash="", + chain=self.config.chain_id, + status="failed", + error=str(e), + ) + + def estimate_fee(self, from_address: str, to_address: str, amount: Decimal) -> GasEstimate: + # Solana has a flat base fee of 5000 lamports per signature + # Priority fees are optional and variable + fee_lamports = 5000 + fee_sol = Decimal(fee_lamports) / Decimal(_LAMPORTS_PER_SOL) + return GasEstimate( + chain=self.config.chain_id, + estimated_fee=fee_sol, + estimated_fee_raw=fee_lamports, + symbol="SOL", + ) + + def validate_address(self, address: str) -> bool: + try: + Pubkey.from_string(address) + return True + except (ValueError, Exception): + return False + + def generate_keypair(self) -> tuple[str, str]: + """Generate a new Solana keypair. Returns (address, private_key_hex). + + Stores the full 64-byte keypair (secret + public) because + solders.Keypair.from_bytes() requires it. + """ + kp = Keypair() + return str(kp.pubkey()), bytes(kp).hex() + + @staticmethod + def address_from_key(private_key: str) -> str: + """Derive address from a private key.""" + key_bytes = bytes.fromhex(private_key) + kp = Keypair.from_bytes(key_bytes) + return str(kp.pubkey()) + + def explorer_tx_url(self, tx_hash: str) -> str: + cluster_param = "" + if self.config.is_testnet: + cluster_param = "?cluster=devnet" + return f"{self.config.explorer_url}/tx/{tx_hash}{cluster_param}" diff --git a/wallet/cli.py b/wallet/cli.py new file mode 100644 index 00000000000..74de1037347 --- /dev/null +++ b/wallet/cli.py @@ -0,0 +1,408 @@ +"""CLI subcommands for ``hermes wallet``. + +Provides: + hermes wallet create — Create a new wallet + hermes wallet create-agent — Create an agent wallet (with auto-approve policies) + hermes wallet import — Import from private key + hermes wallet list — List wallets + hermes wallet balance — Check balance + hermes wallet send — Send tokens (interactive approval) + hermes wallet fund — Show deposit address + hermes wallet history — Transaction history + hermes wallet freeze — Kill switch + hermes wallet unfreeze — Resume operations + hermes wallet status — Show wallet status +""" + +import argparse +import getpass +import json +import sys +from decimal import Decimal, InvalidOperation + +try: + from rich.console import Console + from rich.table import Table + _RICH = True +except ImportError: + _RICH = False + + +def _cprint(msg: str, style: str = "") -> None: + if _RICH: + Console().print(msg, style=style) + else: + print(msg) + + +def _get_wallet_manager(): + """Initialize and return the wallet manager + policy engine.""" + try: + from keystore.client import get_keystore + from wallet.manager import WalletManager + from wallet.policy import PolicyEngine + + ks = get_keystore() + if not ks.is_unlocked: + from keystore.store import KeystoreLocked + raise KeystoreLocked("Keystore is locked") + + mgr = WalletManager(ks) + policy = PolicyEngine() + + # Register providers + try: + from wallet.chains.evm import EVMProvider, EVM_CHAINS + for chain_id, config in EVM_CHAINS.items(): + mgr.register_provider(chain_id, EVMProvider(config)) + except ImportError: + pass + try: + from wallet.chains.solana import SolanaProvider, SOLANA_CHAINS + for chain_id, config in SOLANA_CHAINS.items(): + mgr.register_provider(chain_id, SolanaProvider(config)) + except ImportError: + pass + + return mgr, policy + except ImportError as e: + _cprint(f"\n ✗ Wallet dependencies not installed: {e}", style="bold red") + _cprint(" Install with: pip install 'hermes-agent[wallet]'\n") + sys.exit(1) + + +# ========================================================================= +# Subcommand handlers +# ========================================================================= + +def cmd_wallet_create(args: argparse.Namespace) -> None: + """Create a new wallet.""" + mgr, _ = _get_wallet_manager() + chain = args.chain + label = args.label or "" + + if chain not in mgr.supported_chains: + _cprint(f"\n ✗ Unsupported chain: {chain}", style="bold red") + _cprint(f" Available: {', '.join(mgr.supported_chains)}\n") + return + + wallet = mgr.create_wallet(chain=chain, label=label, wallet_type="user") + + _cprint(f"\n ✓ Wallet created!", style="bold green") + _cprint(f" Label: {wallet.label}") + _cprint(f" Chain: {wallet.chain}") + _cprint(f" Address: {wallet.address}") + _cprint(f" ID: {wallet.wallet_id}") + _cprint(f"\n 💡 Fund this wallet by sending tokens to the address above.\n") + + +def cmd_wallet_create_agent(args: argparse.Namespace) -> None: + """Create an agent wallet with auto-approve policies.""" + mgr, _ = _get_wallet_manager() + chain = args.chain + label = args.label or "Agent Wallet" + + if chain not in mgr.supported_chains: + _cprint(f"\n ✗ Unsupported chain: {chain}", style="bold red") + return + + wallet = mgr.create_wallet(chain=chain, label=label, wallet_type="agent") + + _cprint(f"\n ✓ Agent wallet created!", style="bold green") + _cprint(f" Label: {wallet.label}") + _cprint(f" Chain: {wallet.chain}") + _cprint(f" Address: {wallet.address}") + _cprint(f" ID: {wallet.wallet_id}") + _cprint(f"\n ⚠️ Agent wallets auto-approve transactions within policy limits.") + _cprint(f" Default: max 1.0 {mgr.get_provider(chain).config.symbol}/tx, 5.0/day\n") + + +def cmd_wallet_import(args: argparse.Namespace) -> None: + """Import a wallet from a private key.""" + mgr, _ = _get_wallet_manager() + chain = args.chain + label = args.label or "" + + private_key = getpass.getpass(" Private key (hex, hidden): ") + if not private_key: + _cprint("\n ✗ Cancelled\n", style="yellow") + return + + try: + wallet = mgr.import_wallet(chain=chain, private_key=private_key.strip(), label=label) + _cprint(f"\n ✓ Wallet imported!", style="bold green") + _cprint(f" Label: {wallet.label}") + _cprint(f" Chain: {wallet.chain}") + _cprint(f" Address: {wallet.address}") + _cprint(f" ID: {wallet.wallet_id}\n") + except Exception as e: + _cprint(f"\n ✗ Import failed: {e}\n", style="bold red") + + +def cmd_wallet_list(args: argparse.Namespace) -> None: + """List all wallets.""" + mgr, _ = _get_wallet_manager() + wallets = mgr.list_wallets() + + if not wallets: + _cprint("\n No wallets found. Create one with: hermes wallet create --chain \n") + return + + if _RICH: + console = Console() + table = Table(title="Wallets", show_lines=False) + table.add_column("ID", style="dim") + table.add_column("Label", style="cyan") + table.add_column("Chain", style="magenta") + table.add_column("Type") + table.add_column("Address", style="green") + table.add_column("Balance", justify="right") + + for w in wallets: + try: + bal = mgr.get_balance(w.wallet_id) + balance_str = f"{bal.balance:.6f} {bal.symbol}" + except Exception: + balance_str = "?" + + type_style = "yellow" if w.wallet_type == "agent" else "blue" + table.add_row( + w.wallet_id, + w.label, + w.chain, + f"[{type_style}]{w.wallet_type}[/{type_style}]", + w.address, + balance_str, + ) + console.print() + console.print(table) + console.print() + else: + for w in wallets: + _cprint(f" {w.wallet_id} {w.label} ({w.chain}, {w.wallet_type}) {w.address}") + _cprint("") + + +def cmd_wallet_balance(args: argparse.Namespace) -> None: + """Check wallet balance.""" + mgr, _ = _get_wallet_manager() + try: + wallet = mgr.resolve_wallet(wallet_id=args.wallet_id, chain=args.chain) + bal = mgr.get_balance(wallet.wallet_id) + _cprint(f"\n {wallet.label} ({wallet.chain})") + _cprint(f" Address: {wallet.address}") + _cprint(f" Balance: {bal.balance:.9f} {bal.symbol}\n", style="bold green") + except Exception as e: + _cprint(f"\n ✗ {e}\n", style="bold red") + + +def cmd_wallet_send(args: argparse.Namespace) -> None: + """Send tokens (with interactive approval).""" + mgr, policy = _get_wallet_manager() + + to_address = args.to + try: + amount = Decimal(args.amount) + except InvalidOperation: + _cprint(f"\n ✗ Invalid amount: {args.amount}\n", style="bold red") + return + + try: + wallet = mgr.resolve_wallet(wallet_id=args.wallet_id, chain=args.chain) + except Exception as e: + _cprint(f"\n ✗ {e}\n", style="bold red") + return + + provider = mgr.get_provider(wallet.chain) + symbol = provider.config.symbol + + # Show confirmation + _cprint(f"\n 📤 Send Transaction") + _cprint(f" From: {wallet.label} ({wallet.address})") + _cprint(f" To: {to_address}") + _cprint(f" Amount: {amount} {symbol}") + _cprint(f" Chain: {provider.config.display_name}") + + # Estimate fee + try: + fee = mgr.estimate_fee(wallet.wallet_id, to_address, amount) + _cprint(f" Fee: ~{fee.estimated_fee:.6f} {fee.symbol}") + except Exception: + _cprint(f" Fee: (estimate unavailable)") + + _cprint("") + + # Confirm + try: + confirm = input(" Confirm? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + confirm = "n" + + if confirm not in ("y", "yes"): + _cprint("\n ✗ Cancelled\n", style="yellow") + return + + # Execute + try: + result = mgr.send(wallet.wallet_id, to_address, amount, decided_by="owner_cli") + if result.status == "failed": + _cprint(f"\n ✗ Transaction failed: {result.error}\n", style="bold red") + else: + _cprint(f"\n ✓ Transaction submitted!", style="bold green") + _cprint(f" TX hash: {result.tx_hash}") + if result.explorer_url: + _cprint(f" Explorer: {result.explorer_url}") + _cprint("") + except Exception as e: + _cprint(f"\n ✗ Error: {e}\n", style="bold red") + + +def cmd_wallet_fund(args: argparse.Namespace) -> None: + """Show deposit address for a wallet.""" + mgr, _ = _get_wallet_manager() + try: + wallet = mgr.resolve_wallet(wallet_id=args.wallet_id, chain=args.chain) + provider = mgr.get_provider(wallet.chain) + _cprint(f"\n 💰 Fund Wallet: {wallet.label}") + _cprint(f" Chain: {provider.config.display_name}") + _cprint(f"\n Send {provider.config.symbol} to:") + _cprint(f" {wallet.address}", style="bold green") + if provider.config.is_testnet: + _cprint(f"\n ⚠️ This is a testnet wallet — use testnet faucets") + _cprint("") + except Exception as e: + _cprint(f"\n ✗ {e}\n", style="bold red") + + +def cmd_wallet_history(args: argparse.Namespace) -> None: + """Show transaction history.""" + mgr, _ = _get_wallet_manager() + records = mgr.get_tx_history(wallet_id=args.wallet_id, limit=args.limit) + + if not records: + _cprint("\n No transactions yet.\n") + return + + if _RICH: + console = Console() + table = Table(title="Transaction History", show_lines=False) + table.add_column("Time", style="dim") + table.add_column("Chain") + table.add_column("To", style="cyan") + table.add_column("Amount", justify="right") + table.add_column("Status") + table.add_column("TX Hash", style="dim") + + _status_style = {"submitted": "yellow", "confirmed": "green", "failed": "red", "rejected": "red"} + for r in records: + ts = r.requested_at[:19].replace("T", " ") if r.requested_at else "" + status_s = _status_style.get(r.status, "white") + to_short = r.to_address[:10] + "..." + r.to_address[-6:] if len(r.to_address) > 20 else r.to_address + hash_short = r.tx_hash[:12] + "..." if r.tx_hash and len(r.tx_hash) > 15 else r.tx_hash or "" + table.add_row(ts, r.chain, to_short, f"{r.amount} {r.symbol}", + f"[{status_s}]{r.status}[/{status_s}]", hash_short) + console.print() + console.print(table) + console.print() + else: + for r in records: + _cprint(f" {r.requested_at[:19]} {r.chain} {r.amount} {r.symbol} → {r.to_address[:16]}... {r.status}") + _cprint("") + + +def cmd_wallet_freeze(args: argparse.Namespace) -> None: + """Activate kill switch — block all transactions.""" + _, policy = _get_wallet_manager() + policy.freeze() + _cprint("\n 🔒 Wallet FROZEN — all transactions are blocked.", style="bold red") + _cprint(" Run 'hermes wallet unfreeze' to resume.\n") + + +def cmd_wallet_unfreeze(args: argparse.Namespace) -> None: + """Deactivate kill switch.""" + _, policy = _get_wallet_manager() + policy.unfreeze() + _cprint("\n 🔓 Wallet unfrozen — transactions are allowed.\n", style="green") + + +def cmd_wallet_status(args: argparse.Namespace) -> None: + """Show wallet status overview.""" + mgr, policy = _get_wallet_manager() + wallets = mgr.list_wallets() + + _cprint(f"\n 💰 Wallet Status") + _cprint(f" Wallets: {len(wallets)}") + _cprint(f" Chains: {', '.join(mgr.supported_chains) or 'none'}") + _cprint(f" Frozen: {'YES ⚠️' if policy.is_frozen else 'No'}") + _cprint(f" TX history: {len(mgr.get_tx_history())}") + _cprint("") + + +# ========================================================================= +# Argparse registration +# ========================================================================= + +def register_subparser(subparsers: argparse._SubParsersAction) -> None: + """Register the ``hermes wallet`` subcommand tree.""" + wallet_parser = subparsers.add_parser( + "wallet", + help="Manage crypto wallets", + description="Create, fund, and manage crypto wallets with policy-controlled transactions.", + ) + wallet_parser.set_defaults(func=cmd_wallet_status) + + w_sub = wallet_parser.add_subparsers(dest="wallet_command") + + # create + create_p = w_sub.add_parser("create", help="Create a new wallet") + create_p.add_argument("--chain", "-c", required=True, help="Chain (ethereum, base, solana, etc.)") + create_p.add_argument("--label", "-l", default="", help="Wallet label") + create_p.set_defaults(func=cmd_wallet_create) + + # create-agent + agent_p = w_sub.add_parser("create-agent", help="Create an agent wallet (auto-approve within limits)") + agent_p.add_argument("--chain", "-c", required=True, help="Chain") + agent_p.add_argument("--label", "-l", default="", help="Wallet label") + agent_p.set_defaults(func=cmd_wallet_create_agent) + + # import + import_p = w_sub.add_parser("import", help="Import wallet from private key") + import_p.add_argument("--chain", "-c", required=True, help="Chain") + import_p.add_argument("--label", "-l", default="", help="Wallet label") + import_p.set_defaults(func=cmd_wallet_import) + + # list + w_sub.add_parser("list", aliases=["ls"], help="List all wallets").set_defaults(func=cmd_wallet_list) + + # balance + bal_p = w_sub.add_parser("balance", aliases=["bal"], help="Check wallet balance") + bal_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID") + bal_p.add_argument("--chain", "-c", default=None, help="Chain") + bal_p.set_defaults(func=cmd_wallet_balance) + + # send + send_p = w_sub.add_parser("send", help="Send tokens") + send_p.add_argument("to", help="Recipient address") + send_p.add_argument("amount", help="Amount in native token units") + send_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID") + send_p.add_argument("--chain", "-c", default=None, help="Chain") + send_p.set_defaults(func=cmd_wallet_send) + + # fund + fund_p = w_sub.add_parser("fund", help="Show deposit address") + fund_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID") + fund_p.add_argument("--chain", "-c", default=None, help="Chain") + fund_p.set_defaults(func=cmd_wallet_fund) + + # history + hist_p = w_sub.add_parser("history", help="Transaction history") + hist_p.add_argument("--wallet-id", "-w", default=None, help="Wallet ID") + hist_p.add_argument("--limit", "-n", type=int, default=20, help="Max entries") + hist_p.set_defaults(func=cmd_wallet_history) + + # freeze / unfreeze + w_sub.add_parser("freeze", help="Kill switch — block all transactions").set_defaults(func=cmd_wallet_freeze) + w_sub.add_parser("unfreeze", help="Resume transactions after freeze").set_defaults(func=cmd_wallet_unfreeze) + + # status + w_sub.add_parser("status", help="Show wallet status").set_defaults(func=cmd_wallet_status) diff --git a/wallet/manager.py b/wallet/manager.py new file mode 100644 index 00000000000..305fbd9be87 --- /dev/null +++ b/wallet/manager.py @@ -0,0 +1,340 @@ +"""Wallet manager — creates, stores, and operates wallets. + +Wallets are stored in the keystore as sealed secrets. The manager +is the ONLY code that reads private keys; it passes them to chain +providers for signing and never exposes them outside this module. + +Wallet metadata is stored alongside the key in the keystore: + wallet:meta: → JSON metadata (label, chain, type, address) + wallet::
→ encrypted private key (sealed) +""" + +import json +import logging +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from decimal import Decimal +from typing import Dict, List, Optional + +from keystore.client import KeystoreClient +from wallet.chains import ChainProvider, Balance, TransactionResult, GasEstimate + +logger = logging.getLogger(__name__) + + +@dataclass +class WalletInfo: + """Public wallet information (no private keys).""" + wallet_id: str + label: str + chain: str + address: str + wallet_type: str # "user" | "agent" + created_at: str + + +@dataclass +class TxRecord: + """Local transaction log entry.""" + tx_id: str + wallet_id: str + chain: str + to_address: str + amount: str # Decimal as string for precision + symbol: str + tx_hash: str + status: str # "submitted" | "confirmed" | "failed" | "rejected" + policy_result: str # JSON string + requested_at: str + decided_by: str # "policy_auto" | "owner_cli" | "owner_gateway" + + +class WalletError(Exception): + """Base wallet exception.""" + + +class WalletNotFound(WalletError): + """Wallet ID doesn't exist.""" + + +class WalletManager: + """Manages wallet lifecycle and transaction execution. + + All private key access goes through the keystore with requester="wallet". + The agent process never calls this directly — it goes through the + wallet tools which use session tokens. + """ + + def __init__(self, keystore: KeystoreClient): + self._ks = keystore + self._providers: Dict[str, ChainProvider] = {} + self._tx_log: List[TxRecord] = [] # In-memory for now, persisted to keystore + + # ------------------------------------------------------------------ + # Chain provider management + # ------------------------------------------------------------------ + + def register_provider(self, chain_id: str, provider: ChainProvider) -> None: + """Register a chain provider.""" + self._providers[chain_id] = provider + + def get_provider(self, chain_id: str) -> ChainProvider: + """Get the provider for a chain. Raises WalletError if not registered.""" + if chain_id not in self._providers: + raise WalletError( + f"No provider registered for chain '{chain_id}'. " + f"Available: {', '.join(self._providers.keys()) or 'none'}" + ) + return self._providers[chain_id] + + @property + def supported_chains(self) -> List[str]: + return list(self._providers.keys()) + + # ------------------------------------------------------------------ + # Wallet CRUD + # ------------------------------------------------------------------ + + def create_wallet( + self, + chain: str, + label: str = "", + wallet_type: str = "user", + ) -> WalletInfo: + """Create a new wallet (generates a fresh keypair). + + Returns public wallet info. The private key is stored as a sealed + secret in the keystore and never returned. + """ + provider = self.get_provider(chain) + + # Generate keypair + if hasattr(provider, "generate_keypair"): + address, private_key = provider.generate_keypair() + else: + raise WalletError(f"Chain '{chain}' doesn't support key generation") + + wallet_id = f"w_{uuid.uuid4().hex[:12]}" + now = datetime.now(timezone.utc).isoformat() + + if not label: + label = f"{provider.config.display_name} Wallet" + + # Store private key as sealed secret + key_name = f"wallet:{chain}:{address}" + self._ks.set_secret(key_name, private_key, category="sealed", + description=f"Private key for {label}") + + # Store metadata + meta = { + "wallet_id": wallet_id, + "label": label, + "chain": chain, + "address": address, + "wallet_type": wallet_type, + "created_at": now, + } + meta_name = f"wallet:meta:{wallet_id}" + self._ks.set_secret(meta_name, json.dumps(meta), category="sealed", + description=f"Metadata for {label}") + + logger.info("Created %s wallet '%s' on %s: %s", wallet_type, label, chain, address) + + return WalletInfo( + wallet_id=wallet_id, + label=label, + chain=chain, + address=address, + wallet_type=wallet_type, + created_at=now, + ) + + def import_wallet( + self, + chain: str, + private_key: str, + label: str = "", + wallet_type: str = "user", + ) -> WalletInfo: + """Import an existing wallet from a private key.""" + provider = self.get_provider(chain) + + # Derive address from key + if hasattr(provider, "address_from_key"): + address = provider.address_from_key(private_key) + else: + raise WalletError(f"Chain '{chain}' doesn't support key import") + + wallet_id = f"w_{uuid.uuid4().hex[:12]}" + now = datetime.now(timezone.utc).isoformat() + + if not label: + label = f"Imported {provider.config.display_name} Wallet" + + # Store private key + key_name = f"wallet:{chain}:{address}" + self._ks.set_secret(key_name, private_key, category="sealed", + description=f"Private key for {label}") + + # Store metadata + meta = { + "wallet_id": wallet_id, + "label": label, + "chain": chain, + "address": address, + "wallet_type": wallet_type, + "created_at": now, + } + meta_name = f"wallet:meta:{wallet_id}" + self._ks.set_secret(meta_name, json.dumps(meta), category="sealed", + description=f"Metadata for {label}") + + logger.info("Imported wallet '%s' on %s: %s", label, chain, address) + + return WalletInfo( + wallet_id=wallet_id, + label=label, + chain=chain, + address=address, + wallet_type=wallet_type, + created_at=now, + ) + + def list_wallets(self) -> List[WalletInfo]: + """List all wallets (public info only).""" + wallets = [] + for secret in self._ks.list_secrets(): + if secret.name.startswith("wallet:meta:"): + meta_json = self._ks.get_secret(secret.name, requester="wallet") + if meta_json: + try: + meta = json.loads(meta_json) + wallets.append(WalletInfo(**meta)) + except (json.JSONDecodeError, TypeError) as e: + logger.warning("Corrupt wallet metadata: %s: %s", secret.name, e) + return wallets + + def get_wallet(self, wallet_id: str) -> WalletInfo: + """Get wallet info by ID.""" + meta_json = self._ks.get_secret(f"wallet:meta:{wallet_id}", requester="wallet") + if not meta_json: + raise WalletNotFound(f"Wallet '{wallet_id}' not found") + meta = json.loads(meta_json) + return WalletInfo(**meta) + + def delete_wallet(self, wallet_id: str) -> bool: + """Delete a wallet and its private key.""" + try: + wallet = self.get_wallet(wallet_id) + except WalletNotFound: + return False + + # Delete key and metadata + key_name = f"wallet:{wallet.chain}:{wallet.address}" + self._ks.delete_secret(key_name) + self._ks.delete_secret(f"wallet:meta:{wallet_id}") + logger.info("Deleted wallet '%s' (%s)", wallet.label, wallet.address) + return True + + # ------------------------------------------------------------------ + # Balance & Transactions + # ------------------------------------------------------------------ + + def get_balance(self, wallet_id: str) -> Balance: + """Get the balance for a wallet.""" + wallet = self.get_wallet(wallet_id) + provider = self.get_provider(wallet.chain) + return provider.get_balance(wallet.address) + + def estimate_fee(self, wallet_id: str, to_address: str, amount: Decimal) -> GasEstimate: + """Estimate transaction fee.""" + wallet = self.get_wallet(wallet_id) + provider = self.get_provider(wallet.chain) + return provider.estimate_fee(wallet.address, to_address, amount) + + def send( + self, + wallet_id: str, + to_address: str, + amount: Decimal, + decided_by: str = "owner_cli", + ) -> TransactionResult: + """Execute a native token transfer. + + This is the ONLY method that reads the private key from the keystore. + It should only be called after policy evaluation and approval. + """ + wallet = self.get_wallet(wallet_id) + provider = self.get_provider(wallet.chain) + + # Validate recipient + if not provider.validate_address(to_address): + return TransactionResult( + tx_hash="", chain=wallet.chain, status="failed", + error=f"Invalid address for {wallet.chain}: {to_address}", + ) + + # Read private key (sealed — only wallet requester can access) + key_name = f"wallet:{wallet.chain}:{wallet.address}" + private_key = self._ks.get_secret(key_name, requester="wallet") + if not private_key: + return TransactionResult( + tx_hash="", chain=wallet.chain, status="failed", + error="Failed to retrieve private key from keystore", + ) + + # Execute transaction + result = provider.send_transaction(private_key, to_address, amount) + + # Log transaction + tx_record = TxRecord( + tx_id=f"tx_{uuid.uuid4().hex[:12]}", + wallet_id=wallet_id, + chain=wallet.chain, + to_address=to_address, + amount=str(amount), + symbol=provider.config.symbol, + tx_hash=result.tx_hash, + status=result.status, + policy_result="{}", + requested_at=datetime.now(timezone.utc).isoformat(), + decided_by=decided_by, + ) + self._tx_log.append(tx_record) + + return result + + def get_tx_history(self, wallet_id: Optional[str] = None, limit: int = 20) -> List[TxRecord]: + """Get transaction history from local log.""" + records = self._tx_log + if wallet_id: + records = [r for r in records if r.wallet_id == wallet_id] + return records[-limit:] + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def resolve_wallet(self, wallet_id: Optional[str] = None, chain: Optional[str] = None) -> WalletInfo: + """Resolve a wallet — by ID, by chain (first match), or the only wallet.""" + if wallet_id: + return self.get_wallet(wallet_id) + + wallets = self.list_wallets() + if not wallets: + raise WalletNotFound("No wallets found. Create one with 'hermes wallet create'") + + if chain: + matching = [w for w in wallets if w.chain == chain] + if not matching: + raise WalletNotFound(f"No wallet found for chain '{chain}'") + return matching[0] + + if len(wallets) == 1: + return wallets[0] + + raise WalletError( + f"Multiple wallets found. Specify --wallet-id or --chain. " + f"Wallets: {', '.join(f'{w.label} ({w.chain})' for w in wallets)}" + ) diff --git a/wallet/policy.py b/wallet/policy.py new file mode 100644 index 00000000000..b623dd0a0ad --- /dev/null +++ b/wallet/policy.py @@ -0,0 +1,239 @@ +"""Policy engine — evaluates transactions against configurable rules. + +Policies are checked in order. The first ``block`` or ``require_approval`` +result wins. If all policies pass, the transaction is auto-approved. + +For v1, policies are in-memory (loaded from config.yaml). A future +version will persist per-wallet policies in the keystore. +""" + +import logging +import time +from collections import defaultdict +from dataclasses import dataclass, field +from decimal import Decimal +from enum import Enum +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class PolicyVerdict(str, Enum): + ALLOW = "allow" + REQUIRE_APPROVAL = "require_approval" + BLOCK = "block" + + +@dataclass +class PolicyResult: + """Result of evaluating all policies for a transaction.""" + verdict: PolicyVerdict + reason: str = "" + checked: List[str] = field(default_factory=list) # Policy names that passed + failed: str = "" # Policy name that blocked/required approval + + +@dataclass +class TxRequest: + """A pending transaction to evaluate.""" + wallet_id: str + wallet_type: str # "user" | "agent" + chain: str + to_address: str + amount: Decimal + symbol: str + + +# --------------------------------------------------------------------------- +# Individual policy checks +# --------------------------------------------------------------------------- + +def _check_spending_limit(tx: TxRequest, config: dict) -> Optional[PolicyVerdict]: + """Block if single transaction exceeds max amount.""" + max_amount = Decimal(str(config.get("max_native", "0"))) + if max_amount > 0 and tx.amount > max_amount: + return PolicyVerdict.BLOCK + return None + + +def _check_daily_limit(tx: TxRequest, config: dict, daily_totals: Dict[str, Decimal]) -> Optional[PolicyVerdict]: + """Block if daily aggregate exceeds limit.""" + max_daily = Decimal(str(config.get("max_native", "0"))) + if max_daily <= 0: + return None + today_key = f"{tx.wallet_id}:{time.strftime('%Y-%m-%d')}" + current_total = daily_totals.get(today_key, Decimal("0")) + if current_total + tx.amount > max_daily: + return PolicyVerdict.BLOCK + return None + + +def _check_rate_limit(tx: TxRequest, config: dict, tx_timestamps: Dict[str, list]) -> Optional[PolicyVerdict]: + """Block if too many transactions in the time window.""" + max_txns = config.get("max_txns", 0) + window = config.get("window_seconds", 3600) + if max_txns <= 0: + return None + + key = tx.wallet_id + now = time.time() + timestamps = tx_timestamps.get(key, []) + # Prune old timestamps + timestamps = [t for t in timestamps if now - t < window] + tx_timestamps[key] = timestamps + + if len(timestamps) >= max_txns: + return PolicyVerdict.BLOCK + return None + + +def _check_cooldown(tx: TxRequest, config: dict, last_tx_time: Dict[str, float]) -> Optional[PolicyVerdict]: + """Block if not enough time since last transaction.""" + min_seconds = config.get("min_seconds", 0) + if min_seconds <= 0: + return None + key = tx.wallet_id + last = last_tx_time.get(key, 0) + if time.time() - last < min_seconds: + return PolicyVerdict.BLOCK + return None + + +def _check_allowed_recipients(tx: TxRequest, config: dict) -> Optional[PolicyVerdict]: + """Block if recipient not in allowlist (when configured).""" + addresses = config.get("addresses", []) + if not addresses: + return None # No allowlist = allow all + if tx.to_address.lower() not in [a.lower() for a in addresses]: + return PolicyVerdict.BLOCK + return None + + +def _check_blocked_recipients(tx: TxRequest, config: dict) -> Optional[PolicyVerdict]: + """Block if recipient is in blocklist.""" + addresses = config.get("addresses", []) + if tx.to_address.lower() in [a.lower() for a in addresses]: + return PolicyVerdict.BLOCK + return None + + +def _check_require_approval(tx: TxRequest, config: dict) -> Optional[PolicyVerdict]: + """Require owner approval if amount exceeds threshold.""" + above = Decimal(str(config.get("above_native", "-1"))) + if above < 0: + return None # Not configured + if tx.amount > above: + return PolicyVerdict.REQUIRE_APPROVAL + return None + + +# --------------------------------------------------------------------------- +# Policy engine +# --------------------------------------------------------------------------- + +# Default policies for agent wallets (can be tightened, not loosened) +AGENT_WALLET_DEFAULTS = { + "spending_limit": {"max_native": "1.0"}, # Max per tx (in native token) + "daily_limit": {"max_native": "5.0"}, # Max per day + "rate_limit": {"max_txns": 5, "window_seconds": 3600}, + "cooldown": {"min_seconds": 30}, + "require_approval": {"above_native": "0.5"}, # Require approval above this +} + +# User wallets always require approval by default +USER_WALLET_DEFAULTS = { + "require_approval": {"above_native": "0"}, # Always require approval +} + + +class PolicyEngine: + """Evaluates transactions against a set of policies.""" + + def __init__(self, policies: Optional[Dict[str, dict]] = None): + self._policies = policies or {} + # Tracking state for rate-based policies + self._daily_totals: Dict[str, Decimal] = defaultdict(Decimal) + self._tx_timestamps: Dict[str, list] = defaultdict(list) + self._last_tx_time: Dict[str, float] = {} + self._frozen = False + + @property + def is_frozen(self) -> bool: + return self._frozen + + def freeze(self) -> None: + """Kill switch — block all transactions.""" + self._frozen = True + logger.warning("Wallet FROZEN — all transactions blocked") + + def unfreeze(self) -> None: + """Unfreeze — resume normal operation.""" + self._frozen = False + logger.info("Wallet unfrozen") + + def evaluate(self, tx: TxRequest) -> PolicyResult: + """Evaluate a transaction against all policies. + + Returns PolicyResult with the final verdict. + """ + if self._frozen: + return PolicyResult( + verdict=PolicyVerdict.BLOCK, + reason="Wallet is frozen (kill switch active)", + failed="freeze", + ) + + # Select policy set based on wallet type + if tx.wallet_type == "agent": + policies = {**AGENT_WALLET_DEFAULTS, **self._policies} + else: + policies = {**USER_WALLET_DEFAULTS, **self._policies} + + checked = [] + + # Check each policy + _CHECKS = { + "spending_limit": lambda cfg: _check_spending_limit(tx, cfg), + "daily_limit": lambda cfg: _check_daily_limit(tx, cfg, self._daily_totals), + "rate_limit": lambda cfg: _check_rate_limit(tx, cfg, self._tx_timestamps), + "cooldown": lambda cfg: _check_cooldown(tx, cfg, self._last_tx_time), + "allowed_recipients": lambda cfg: _check_allowed_recipients(tx, cfg), + "blocked_recipients": lambda cfg: _check_blocked_recipients(tx, cfg), + "require_approval": lambda cfg: _check_require_approval(tx, cfg), + } + + for policy_name, config in policies.items(): + check_fn = _CHECKS.get(policy_name) + if not check_fn: + continue + + result = check_fn(config) + if result == PolicyVerdict.BLOCK: + return PolicyResult( + verdict=PolicyVerdict.BLOCK, + reason=f"Blocked by {policy_name} policy", + checked=checked, + failed=policy_name, + ) + elif result == PolicyVerdict.REQUIRE_APPROVAL: + return PolicyResult( + verdict=PolicyVerdict.REQUIRE_APPROVAL, + reason=f"Requires approval ({policy_name} policy)", + checked=checked, + failed=policy_name, + ) + checked.append(policy_name) + + return PolicyResult( + verdict=PolicyVerdict.ALLOW, + reason="All policies passed", + checked=checked, + ) + + def record_transaction(self, tx: TxRequest) -> None: + """Update tracking state after a successful transaction.""" + today_key = f"{tx.wallet_id}:{time.strftime('%Y-%m-%d')}" + self._daily_totals[today_key] += tx.amount + + self._tx_timestamps[tx.wallet_id].append(time.time()) + self._last_tx_time[tx.wallet_id] = time.time()