Files
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

203 lines
7.5 KiB
Python

"""HTTP client for a hanzoai/mpc (or luxfi/mpc) threshold-signing cluster.
Minimal, pydantic-typed, HTTP-only (no NATS/Consul deps). Designed for
``Web3Agent`` to confirm signing through a real MPC cluster instead of
the placeholder ``return True``.
Wire protocol assumed (matches the HTTP bridge documented in
``hanzoai/mpc`` ``docs/BRIDGE-INTEGRATION.md``):
::
POST {endpoint}/v1/keygen -> {"public_key": "<hex>", "key_id": "<id>"}
POST {endpoint}/v1/sign {"key_id": "<id>", "digest": "<hex>"}
-> {"signature": "<hex>", "scheme": "ecdsa|ed25519"}
POST {endpoint}/v1/verify {"signature": "<hex>", "digest": "<hex>",
"public_key": "<hex>"}
-> {"valid": true|false}
Each call retries transient failures (5xx, network) up to ``max_retries``
with exponential backoff. ``timeout`` is per-attempt.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Optional
try:
import aiohttp
from aiohttp import ClientError, ClientResponseError
AIOHTTP_AVAILABLE = True
except ImportError: # pragma: no cover - dependency declared in pyproject
AIOHTTP_AVAILABLE = False
aiohttp = None # type: ignore[assignment]
ClientError = Exception # type: ignore[assignment,misc]
ClientResponseError = Exception # type: ignore[assignment,misc]
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class MpcError(RuntimeError):
"""Raised when the MPC cluster cannot satisfy a request."""
class MpcConfig(BaseModel):
"""Configuration for :class:`MpcClient`.
``endpoint`` is the base URL of the HTTP bridge in front of the MPC
cluster (no trailing slash). ``threshold`` and ``parties`` are
advisory values used at keygen time; the cluster is the source of
truth for the actual quorum.
"""
endpoint: str = Field(default="http://127.0.0.1:8080", description="HTTP bridge base URL")
timeout_seconds: float = Field(default=10.0, ge=0.1, le=120.0)
max_retries: int = Field(default=3, ge=0, le=10)
backoff_seconds: float = Field(default=0.5, ge=0.0, le=10.0)
parties: int = Field(default=3, ge=1, le=21, description="Total signing parties (n)")
threshold: int = Field(default=2, ge=1, le=21, description="Quorum threshold (t)")
api_key: Optional[str] = Field(default=None, description="Bearer token, if cluster requires one")
class KeygenResult(BaseModel):
public_key: str
key_id: str
class SignResult(BaseModel):
signature: str
scheme: str = "ecdsa"
class VerifyResult(BaseModel):
valid: bool
class MpcClient:
"""Async HTTP client for a threshold-signing MPC cluster.
The client is stateless across calls; each call opens its own
short-lived :class:`aiohttp.ClientSession`. For high-throughput
callers, pass an existing session via ``session=``.
"""
def __init__(
self,
config: Optional[MpcConfig] = None,
*,
session: "Optional[aiohttp.ClientSession]" = None,
) -> None:
if not AIOHTTP_AVAILABLE:
raise ImportError(
"MpcClient requires aiohttp. Install with: pip install aiohttp"
)
self.config = config or MpcConfig()
self._external_session = session
# ---------------------------------------------------------------- internals
def _headers(self) -> dict:
h = {"Accept": "application/json", "Content-Type": "application/json"}
if self.config.api_key:
h["Authorization"] = f"Bearer {self.config.api_key}"
return h
async def _request(self, path: str, payload: dict) -> dict:
url = f"{self.config.endpoint.rstrip('/')}{path}"
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)
last_err: Optional[Exception] = None
for attempt in range(self.config.max_retries + 1):
session = self._external_session
owns_session = session is None
if owns_session:
session = aiohttp.ClientSession(timeout=timeout)
try:
async with session.post(url, json=payload, headers=self._headers()) as resp:
body = await resp.text()
if resp.status >= 500:
raise ClientResponseError(
resp.request_info, resp.history,
status=resp.status, message=body,
)
if resp.status >= 400:
# Client errors are not retried.
raise MpcError(f"MPC bridge {resp.status}: {body[:200]}")
return await _parse_json(body, resp.headers.get("Content-Type", ""))
except (ClientError, asyncio.TimeoutError, ClientResponseError) as exc:
last_err = exc
if attempt >= self.config.max_retries:
break
delay = self.config.backoff_seconds * (2 ** attempt)
logger.warning(
"MPC request to %s failed (%s). Retrying in %.2fs (attempt %d/%d)",
url, exc, delay, attempt + 1, self.config.max_retries,
)
await asyncio.sleep(delay)
finally:
if owns_session and session is not None:
await session.close()
raise MpcError(f"MPC bridge unreachable after {self.config.max_retries + 1} attempts: {last_err}")
# ------------------------------------------------------------------- public
async def keygen(self) -> KeygenResult:
"""Generate a new threshold key. Returns the public key and a key id."""
body = await self._request(
"/v1/keygen",
{"parties": self.config.parties, "threshold": self.config.threshold},
)
return KeygenResult(**body)
async def sign(self, digest_hex: str, *, key_id: str) -> SignResult:
"""Threshold-sign ``digest_hex`` (32-byte hex string) under ``key_id``."""
if not _looks_like_hex(digest_hex):
raise MpcError("digest_hex must be a hex-encoded byte string")
body = await self._request("/v1/sign", {"key_id": key_id, "digest": digest_hex})
return SignResult(**body)
async def verify(self, signature_hex: str, digest_hex: str, public_key_hex: str) -> VerifyResult:
"""Ask the cluster to verify a signature. Useful for cross-check
before trusting a payment ack signed by another party."""
if not _looks_like_hex(signature_hex) or not _looks_like_hex(digest_hex) or not _looks_like_hex(public_key_hex):
raise MpcError("signature, digest, and public_key must be hex-encoded")
body = await self._request(
"/v1/verify",
{"signature": signature_hex, "digest": digest_hex, "public_key": public_key_hex},
)
return VerifyResult(**body)
def _looks_like_hex(s: str) -> bool:
s = s[2:] if s.startswith(("0x", "0X")) else s
if not s:
return False
try:
int(s, 16)
except ValueError:
return False
return True
async def _parse_json(body: str, content_type: str) -> dict:
import json
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise MpcError(f"MPC bridge returned non-JSON ({content_type}): {body[:200]}") from exc
__all__ = [
"MpcClient",
"MpcConfig",
"MpcError",
"KeygenResult",
"SignResult",
"VerifyResult",
]