Files
hanzoai_agent/sdk/python/tests/test_web3_mpc_client.py
T
Abhishek Krishna 38e687e42e feat(web3): MPC-signing integration for Web3 agents (closes #3)
Closes the two integration stubs and one broken import that block
hanzo_agents.extensions.web3 from doing real threshold-signed work:

1. New `mpc_client.py` — pydantic-typed `MpcClient` with `keygen`,
   `sign`, `verify`. HTTP-only against the `hanzoai/mpc` HTTP bridge
   (`POST /v1/{keygen,sign,verify}`). Configurable endpoint, timeout,
   retries, parties/threshold, optional bearer auth. Retries 5xx with
   exponential backoff; 4xx fail fast.

2. `web3_agent.verify_payment` — actually checks the chain. Pulls the
   tx receipt + tx, validates status==1, value >= expected (in wei),
   recipient == self.wallet.address, and >= configurable confirmations.
   Optional MPC cross-verification of the block-hash signature so a
   single party cannot spoof an ack. Mock-wallet path preserves legacy
   "trust the caller" behavior so existing tests keep passing.

3. `wallet.verify_signature` — uses `eth_account.Account.recover_message`
   with the EIP-191 prefix (matches `sign_message`). Returns False on
   malformed input rather than raising.

4. Fix `from .tee import …` → `from ..tee import …`. The `tee` package
   lives at `hanzo_agents.extensions.tee`, not under `extensions.web3`,
   so `from hanzo_agents.extensions.web3 import Web3Agent` currently
   raises `ModuleNotFoundError`.

Tests: `tests/test_web3_mpc_client.py` mocks `aiohttp.ClientSession`
and covers happy path for keygen/sign/verify, retry-on-5xx, fail-fast
on 4xx, hex validation, bearer-token header.

Docs: `docs/web3-mpc-integration.md` shows enable + wire protocol +
failure modes.

No new top-level deps. `aiohttp`, `pydantic`, `eth-account` are
already declared in `pyproject.toml`.

Total ~430 LOC across 1 new module, 2 patches, 1 test file, 1 doc.
2026-04-25 14:19:46 +05:30

174 lines
5.0 KiB
Python

"""Tests for hanzo_agents.extensions.web3.mpc_client.
Mocks aiohttp.ClientSession so the MPC HTTP bridge does not need to be
running. Covers: keygen, sign, verify, retry-on-5xx, hard-fail-on-4xx,
and bad-input validation.
"""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hanzo_agents.extensions.web3.mpc_client import (
KeygenResult,
MpcClient,
MpcConfig,
MpcError,
SignResult,
VerifyResult,
)
# --------------------------------------------------------------------- helpers
class _FakeResponse:
def __init__(self, status: int, body: dict | str):
self.status = status
self._body = body if isinstance(body, str) else json.dumps(body)
self.headers = {"Content-Type": "application/json"}
self.request_info = MagicMock()
self.history = ()
async def text(self):
return self._body
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
def _patched_session(responses):
"""Return a ClientSession factory whose `.post(...)` yields each
queued response in order, then raises if asked again."""
iterator = iter(responses)
class _Session:
def __init__(self, *_, **__):
pass
def post(self, *_, **__):
try:
return next(iterator)
except StopIteration:
raise AssertionError("MpcClient made more HTTP calls than test queued")
async def close(self):
return None
return _Session
# ----------------------------------------------------------------------- tests
def test_config_defaults_are_sane():
cfg = MpcConfig()
assert cfg.endpoint.startswith("http")
assert cfg.timeout_seconds > 0
assert cfg.threshold <= cfg.parties
def test_config_rejects_zero_timeout():
with pytest.raises(Exception):
MpcConfig(timeout_seconds=0)
@pytest.mark.asyncio
async def test_keygen_happy_path():
fake = _patched_session([_FakeResponse(200, {"public_key": "0xabc", "key_id": "k1"})])
with patch("aiohttp.ClientSession", fake):
client = MpcClient(MpcConfig(max_retries=0))
out = await client.keygen()
assert isinstance(out, KeygenResult)
assert out.public_key == "0xabc"
assert out.key_id == "k1"
@pytest.mark.asyncio
async def test_sign_happy_path():
fake = _patched_session([_FakeResponse(200, {"signature": "0xdead", "scheme": "ecdsa"})])
with patch("aiohttp.ClientSession", fake):
client = MpcClient(MpcConfig(max_retries=0))
out = await client.sign("0xdeadbeef", key_id="k1")
assert isinstance(out, SignResult)
assert out.signature == "0xdead"
assert out.scheme == "ecdsa"
@pytest.mark.asyncio
async def test_verify_happy_path():
fake = _patched_session([_FakeResponse(200, {"valid": True})])
with patch("aiohttp.ClientSession", fake):
client = MpcClient(MpcConfig(max_retries=0))
out = await client.verify("0xdead", "0xbeef", "0xabc")
assert isinstance(out, VerifyResult)
assert out.valid is True
@pytest.mark.asyncio
async def test_sign_rejects_non_hex():
client = MpcClient(MpcConfig(max_retries=0))
with pytest.raises(MpcError):
await client.sign("not-hex", key_id="k1")
@pytest.mark.asyncio
async def test_4xx_does_not_retry_and_raises():
fake = _patched_session([_FakeResponse(404, "key not found")])
with patch("aiohttp.ClientSession", fake):
client = MpcClient(MpcConfig(max_retries=3))
with pytest.raises(MpcError) as exc:
await client.sign("0xdeadbeef", key_id="missing")
assert "404" in str(exc.value)
@pytest.mark.asyncio
async def test_5xx_retries_then_succeeds():
fake = _patched_session([
_FakeResponse(503, "overloaded"),
_FakeResponse(200, {"public_key": "0xabc", "key_id": "k1"}),
])
with patch("aiohttp.ClientSession", fake):
client = MpcClient(MpcConfig(max_retries=2, backoff_seconds=0.0))
out = await client.keygen()
assert out.key_id == "k1"
@pytest.mark.asyncio
async def test_5xx_exhausts_retries_and_raises():
fake = _patched_session([
_FakeResponse(500, "boom"),
_FakeResponse(500, "boom"),
_FakeResponse(500, "boom"),
])
with patch("aiohttp.ClientSession", fake):
client = MpcClient(MpcConfig(max_retries=2, backoff_seconds=0.0))
with pytest.raises(MpcError):
await client.keygen()
@pytest.mark.asyncio
async def test_api_key_sets_authorization_header():
captured: dict = {}
class _Capture:
def __init__(self, *_, **__):
pass
def post(self, *args, **kwargs):
captured.update(kwargs.get("headers", {}))
return _FakeResponse(200, {"public_key": "0xabc", "key_id": "k1"})
async def close(self):
return None
with patch("aiohttp.ClientSession", _Capture):
client = MpcClient(MpcConfig(api_key="secret-token", max_retries=0))
await client.keygen()
assert captured.get("Authorization") == "Bearer secret-token"