Files
hermes-agent/tools/wallet_tool.py
Shannon Sands ffefd57719 feat: add wallet module — manager, policy engine, chain providers, tools, CLI
Phase 2 of the wallet architecture — crypto wallet functionality built
on top of the keystore.

Core components:
- wallet/manager.py: Wallet CRUD, balance checks, transaction execution.
  Private keys stored as sealed keystore secrets — only the manager reads
  them, and only to pass to chain providers for signing.
- wallet/policy.py: Transaction policy engine with spending limits, daily
  limits, rate limits, cooldown, recipient allow/blocklists, approval
  thresholds, and a kill switch (freeze/unfreeze).
- wallet/chains/: Abstract ChainProvider interface + EVM and Solana impls.
  EVM supports Ethereum, Base, Polygon, Arbitrum, Optimism + testnets.
  Solana supports mainnet + devnet.

Agent integration:
- tools/wallet_tool.py: 5 agent-facing tools (wallet_list, wallet_balance,
  wallet_send, wallet_history, wallet_estimate_gas). All return JSON,
  none expose private keys. wallet_send goes through the policy engine.
- toolsets.py: New 'wallet' toolset
- model_tools.py: wallet_tool added to discovery list

CLI:
- wallet/cli.py: Full CLI — create, create-agent, import, list, balance,
  send (with interactive confirmation), fund, history, freeze, unfreeze, status
- hermes_cli/main.py: 'hermes wallet' subcommand registered

Policy defaults:
- Agent wallets: 1.0 native/tx max, 5.0/day, 5 txns/hour, 30s cooldown,
  approval required above 0.5 native
- User wallets: owner approval required for all transactions

Tests: 100 passing (28 wallet + 72 keystore)
2026-03-29 08:38:21 +10:00

424 lines
13 KiB
Python

"""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="",
)