Files
hermes-agent/wallet/cli.py

409 lines
15 KiB
Python
Raw Normal View History

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-26 14:02:56 +10:00
"""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)