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

151 lines
4.9 KiB
Python

"""Tests for wallet.policy — transaction policy engine."""
import time
from decimal import Decimal
import pytest
from wallet.policy import (
PolicyEngine,
PolicyResult,
PolicyVerdict,
TxRequest,
AGENT_WALLET_DEFAULTS,
)
def _make_tx(**overrides) -> TxRequest:
defaults = {
"wallet_id": "w_test",
"wallet_type": "agent",
"chain": "ethereum-sepolia",
"to_address": "0x1234567890abcdef1234567890abcdef12345678",
"amount": Decimal("0.01"),
"symbol": "ETH",
}
defaults.update(overrides)
return TxRequest(**defaults)
class TestBasicPolicy:
def test_allow_small_agent_tx(self):
engine = PolicyEngine()
tx = _make_tx(amount=Decimal("0.001"))
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.ALLOW
def test_block_over_spending_limit(self):
engine = PolicyEngine({"spending_limit": {"max_native": "0.5"}})
tx = _make_tx(amount=Decimal("1.0"))
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "spending_limit" in result.failed
def test_require_approval_above_threshold(self):
engine = PolicyEngine({"require_approval": {"above_native": "0.1"}})
tx = _make_tx(amount=Decimal("0.5"), wallet_type="agent")
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.REQUIRE_APPROVAL
def test_user_wallet_always_requires_approval(self):
engine = PolicyEngine()
tx = _make_tx(wallet_type="user", amount=Decimal("0.001"))
result = engine.evaluate(tx)
# User wallet defaults require approval for any amount
assert result.verdict == PolicyVerdict.REQUIRE_APPROVAL
class TestRateLimit:
def test_rate_limit_blocks_after_max(self):
engine = PolicyEngine({"rate_limit": {"max_txns": 2, "window_seconds": 3600}})
tx = _make_tx()
# First two should pass
r1 = engine.evaluate(tx)
engine.record_transaction(tx)
r2 = engine.evaluate(tx)
engine.record_transaction(tx)
# Third should be blocked
r3 = engine.evaluate(tx)
assert r3.verdict == PolicyVerdict.BLOCK
assert "rate_limit" in r3.failed
class TestCooldown:
def test_cooldown_blocks_rapid_txs(self):
engine = PolicyEngine({"cooldown": {"min_seconds": 60}})
tx = _make_tx()
# First is fine
engine.record_transaction(tx)
# Immediate second should be blocked
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "cooldown" in result.failed
class TestRecipientPolicies:
def test_allowed_recipients_blocks_unknown(self):
engine = PolicyEngine({
"allowed_recipients": {"addresses": ["0xAAAA"]},
})
tx = _make_tx(to_address="0xBBBB")
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
def test_allowed_recipients_passes_known(self):
addr = "0x1234567890abcdef1234567890abcdef12345678"
engine = PolicyEngine({
"allowed_recipients": {"addresses": [addr]},
})
tx = _make_tx(to_address=addr)
# Agent defaults may still require approval, but shouldn't be BLOCKED
result = engine.evaluate(tx)
assert result.verdict != PolicyVerdict.BLOCK or result.failed != "allowed_recipients"
def test_blocked_recipients(self):
bad = "0xBADBADBADBADBADBADBADBADBADBADBADBADBADBA"
engine = PolicyEngine({
"blocked_recipients": {"addresses": [bad]},
})
tx = _make_tx(to_address=bad)
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "blocked_recipients" in result.failed
class TestFreezeKillSwitch:
def test_freeze_blocks_everything(self):
engine = PolicyEngine()
engine.freeze()
tx = _make_tx(amount=Decimal("0.0001"))
result = engine.evaluate(tx)
assert result.verdict == PolicyVerdict.BLOCK
assert "frozen" in result.reason.lower()
def test_unfreeze_resumes(self):
engine = PolicyEngine()
engine.freeze()
engine.unfreeze()
tx = _make_tx(amount=Decimal("0.0001"))
result = engine.evaluate(tx)
assert result.verdict != PolicyVerdict.BLOCK or result.failed != "freeze"
class TestDailyLimit:
def test_daily_limit_blocks_aggregate(self):
engine = PolicyEngine({"daily_limit": {"max_native": "0.05"}})
tx = _make_tx(amount=Decimal("0.03"))
# First tx: 0.03 of 0.05 limit
r1 = engine.evaluate(tx)
assert r1.verdict != PolicyVerdict.BLOCK or r1.failed != "daily_limit"
engine.record_transaction(tx)
# Second tx: 0.03 more would be 0.06 > 0.05
r2 = engine.evaluate(tx)
assert r2.verdict == PolicyVerdict.BLOCK
assert "daily_limit" in r2.failed