Files
hermes-agent/tools/wallet_tool.py
Shannon Sands 253c7abbe9 fix(wallet): harden keystore fallback, persist policy/history, wire gateway injection
Addresses review findings:
- Remove insecure automatic encrypted-file credential-store fallback.
   now only uses real OS/keyctl-backed stores,
  or remains unavailable. Headless users must use explicit
  HERMES_KEYSTORE_PASSPHRASE if desired.
- Add shared wallet runtime so tools/CLI/approval use the same configured
  providers and persisted policy state.
- Inject keystore-backed secrets into gateway/headless startup too, so
  migrated .env stubs don't break messaging deployments.
- Persist wallet policy state (freeze, daily totals, rate-limit timestamps,
  cooldown timestamps) across invocations.
- Persist transaction history to disk across invocations.
- Make owner-approved sends execute through the same runtime/policy path and
  record policy state after successful approved sends.
- Fix wallet export by allowing explicit CLI export reads of sealed keys via
  dedicated requester path () instead of generic CLI reads.
- Make CLI wallet sends evaluate policy before execution and honor freeze.
- Align docs with actual crypto primitive (XSalsa20-Poly1305 via SecretBox)
  and current policy-config scope.

Validation:
- 129 tests passing
- freeze persistence verified manually
- wallet export verified manually
2026-03-29 08:38:29 +10:00

548 lines
17 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.
Toolset: ``wallet`` (optional install: ``pip install 'hermes-agent[wallet]'``)
The toolset is gated on:
1. keystore + wallet packages being installed
2. The keystore being initialized and unlocked
3. At least one wallet existing
Tools:
wallet_list — List wallets with addresses and balances
wallet_balance — Check balance of a specific wallet
wallet_send — Send native tokens (policy-gated)
wallet_history — Transaction history
wallet_estimate_gas — Fee estimation
wallet_address — Get a wallet's deposit address (for sharing/receiving)
wallet_networks — List supported and active blockchain networks
"""
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 wallet.runtime import get_runtime
_wallet_manager, _policy_engine = get_runtime()
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 _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:
# Stash the transaction for user approval via CLI or gateway
from wallet.approval import PendingWalletTx, submit_pending
pending_tx = PendingWalletTx(
wallet_id=wallet.wallet_id,
chain=wallet.chain,
from_address=wallet.address,
to_address=to_address,
amount=str(amount),
symbol=symbol,
wallet_label=wallet.label,
wallet_type=wallet.wallet_type,
)
# Use task_id as session key (matches how the agent loop tracks sessions)
session_key = kw.get("task_id") or task_id or "default"
submit_pending(session_key, pending_tx)
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",
policy_result=json.dumps({
"verdict": result.verdict.value,
"checked": result.checked,
"failed": result.failed,
"approved_via": "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)})
def wallet_address(args: dict, task_id: str = None, **kw) -> str:
"""Get a wallet's deposit address — for sharing with others or receiving funds."""
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)
provider = mgr.get_provider(wallet.chain)
return json.dumps({
"wallet": wallet.label,
"wallet_id": wallet.wallet_id,
"chain": wallet.chain,
"network": provider.config.display_name,
"address": wallet.address,
"type": wallet.wallet_type,
"is_testnet": provider.config.is_testnet,
"message": (
f"Address for {wallet.label} on {provider.config.display_name}: "
f"{wallet.address}"
),
})
except Exception as e:
return json.dumps({"error": str(e)})
def wallet_networks(args: dict = None, task_id: str = None, **kw) -> str:
"""List supported blockchain networks and which have active wallets."""
mgr, _ = _get_manager()
if mgr is None:
return json.dumps({"error": "Wallet not available"})
wallets = mgr.list_wallets()
wallet_chains = {w.chain for w in wallets}
networks = []
for chain_id in mgr.supported_chains:
try:
provider = mgr.get_provider(chain_id)
cfg = provider.config
networks.append({
"chain_id": chain_id,
"name": cfg.display_name,
"symbol": cfg.symbol,
"is_testnet": cfg.is_testnet,
"has_wallet": chain_id in wallet_chains,
"rpc_url": cfg.rpc_url,
})
except Exception:
networks.append({"chain_id": chain_id, "status": "error"})
# Group by mainnet/testnet for readability
mainnets = [n for n in networks if not n.get("is_testnet")]
testnets = [n for n in networks if n.get("is_testnet")]
return json.dumps({
"mainnets": mainnets,
"testnets": testnets,
"total_networks": len(networks),
"networks_with_wallets": len(wallet_chains),
})
# =========================================================================
# 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="",
)
registry.register(
name="wallet_address",
toolset="wallet",
schema={
"name": "wallet_address",
"description": (
"Get a wallet's deposit address for receiving funds. "
"Use this to share your wallet address with others or to check "
"which address to send funds to."
),
"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. 'solana', 'ethereum', 'base')",
},
},
"required": [],
},
},
handler=lambda args, **kw: wallet_address(args, **kw),
check_fn=_check_wallet_available,
emoji="📬",
)
registry.register(
name="wallet_networks",
toolset="wallet",
schema={
"name": "wallet_networks",
"description": (
"List all supported blockchain networks and which ones have active wallets. "
"Shows mainnets and testnets separately with their native token symbols."
),
"parameters": {"type": "object", "properties": {}, "required": []},
},
handler=lambda args, **kw: wallet_networks(**kw),
check_fn=_check_wallet_available,
emoji="🌐",
)