mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-02 16:57:36 +08:00
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)
424 lines
13 KiB
Python
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="⛽",
|
|
)
|