Files
hermes-agent/wallet/cli.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

409 lines
15 KiB
Python

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