diff --git a/.changelog/brave-otters-swim.md b/.changelog/brave-otters-swim.md new file mode 100644 index 0000000..5cd2feb --- /dev/null +++ b/.changelog/brave-otters-swim.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Added `mpp.methods.evm`, a payment method for standard EVM chains (e.g. Base) that settles charges with a plain ERC-20 `transfer` instead of Tempo's native transaction type. Install with `pip install "pympp[evm]"`. diff --git a/pyproject.toml b/pyproject.toml index 2cead95..a717e96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,10 @@ tempo = [ stripe = [ "pydantic>=2.0", ] +evm = [ + "eth-account>=0.11", + "eth-hash[pycryptodome]>=0.7", +] server = ["pydantic>=2.0"] redis = ["redis>=5.0"] sqlite = ["aiosqlite>=0.20"] diff --git a/src/mpp/methods/evm/__init__.py b/src/mpp/methods/evm/__init__.py new file mode 100644 index 0000000..48d0f85 --- /dev/null +++ b/src/mpp/methods/evm/__init__.py @@ -0,0 +1,67 @@ +"""Generic EVM payment method (e.g. Base) for HTTP 402 authentication. + +pympp ships payment method implementations for Tempo (its own chain, using +native account-abstraction transactions) and Stripe. This module is the +equivalent for any standard EVM chain: it plugs into the same +``Method``/``Intent`` protocols that ``mpp.methods.tempo`` implements, but +moves value with a plain ERC-20 ``transfer`` instead of Tempo's native +transaction type, since standard EVM chains (e.g. Base) have none of Tempo's +extensions (memos, MACH fee tokens, escrow, access keys). + +Client side: builds and signs a standard EIP-1559 transaction calling +``transfer(address,uint256)`` on the requested ERC-20 currency. + +Server side: broadcasts that signed transaction to an EVM JSON-RPC endpoint +and confirms the mined receipt contains a matching ``Transfer`` log. + +Example: + # Client-side + from mpp.client import get + from mpp.methods.evm import evm, EvmAccount, ChargeIntent + + account = EvmAccount.from_key("0x...") + response = await get( + "https://api.example.com/resource", + methods=[evm( + account=account, + intents={"charge": ChargeIntent()}, + )], + ) + + # Server-side + from mpp.server import Mpp + from mpp.methods.evm import evm, ChargeIntent + + server = Mpp.create( + method=evm( + recipient="0x...", + intents={"charge": ChargeIntent()}, + ), + ) +""" + +from typing import Any + +from mpp._lazy_exports import load_lazy_attr +from mpp.methods.evm._defaults import ( + BASE_CHAIN_ID, + BASE_RPC_URL, + BASE_SEPOLIA_CHAIN_ID, + BASE_SEPOLIA_RPC_URL, + BASE_SEPOLIA_USDC, + BASE_USDC, + default_currency_for_chain, + rpc_url_for_chain, +) + +_EXTRA_INSTALL_HINT = 'Install the "evm" extra to use this module: pip install "pympp[evm]"' + +_LAZY_EXPORTS = { + "mpp.methods.evm.account": ("EvmAccount",), + "mpp.methods.evm.client": ("EvmMethod", "TransactionError", "evm"), + "mpp.methods.evm.intents": ("ChargeIntent",), +} + + +def __getattr__(name: str) -> Any: + return load_lazy_attr(__name__, name, _LAZY_EXPORTS, globals(), _EXTRA_INSTALL_HINT) diff --git a/src/mpp/methods/evm/__init__.pyi b/src/mpp/methods/evm/__init__.pyi new file mode 100644 index 0000000..9761d11 --- /dev/null +++ b/src/mpp/methods/evm/__init__.pyi @@ -0,0 +1,27 @@ +from mpp.methods.evm._defaults import BASE_CHAIN_ID as _BASE_CHAIN_ID +from mpp.methods.evm._defaults import BASE_RPC_URL as _BASE_RPC_URL +from mpp.methods.evm._defaults import BASE_SEPOLIA_CHAIN_ID as _BASE_SEPOLIA_CHAIN_ID +from mpp.methods.evm._defaults import BASE_SEPOLIA_RPC_URL as _BASE_SEPOLIA_RPC_URL +from mpp.methods.evm._defaults import BASE_SEPOLIA_USDC as _BASE_SEPOLIA_USDC +from mpp.methods.evm._defaults import BASE_USDC as _BASE_USDC +from mpp.methods.evm._defaults import default_currency_for_chain as _default_currency_for_chain +from mpp.methods.evm._defaults import rpc_url_for_chain as _rpc_url_for_chain +from mpp.methods.evm.account import EvmAccount as _EvmAccount +from mpp.methods.evm.client import EvmMethod as _EvmMethod +from mpp.methods.evm.client import TransactionError as _TransactionError +from mpp.methods.evm.client import evm as _evm +from mpp.methods.evm.intents import ChargeIntent as _ChargeIntent + +BASE_CHAIN_ID = _BASE_CHAIN_ID +BASE_RPC_URL = _BASE_RPC_URL +BASE_SEPOLIA_CHAIN_ID = _BASE_SEPOLIA_CHAIN_ID +BASE_SEPOLIA_RPC_URL = _BASE_SEPOLIA_RPC_URL +BASE_SEPOLIA_USDC = _BASE_SEPOLIA_USDC +BASE_USDC = _BASE_USDC +default_currency_for_chain = _default_currency_for_chain +rpc_url_for_chain = _rpc_url_for_chain +EvmAccount = _EvmAccount +EvmMethod = _EvmMethod +TransactionError = _TransactionError +evm = _evm +ChargeIntent = _ChargeIntent diff --git a/src/mpp/methods/evm/_defaults.py b/src/mpp/methods/evm/_defaults.py new file mode 100644 index 0000000..7883938 --- /dev/null +++ b/src/mpp/methods/evm/_defaults.py @@ -0,0 +1,53 @@ +"""Shared defaults for the EVM payment method (e.g. Base).""" + +from types import MappingProxyType + +BASE_CHAIN_ID = 8453 +BASE_SEPOLIA_CHAIN_ID = 84532 + +BASE_RPC_URL = "https://mainnet.base.org" +BASE_SEPOLIA_RPC_URL = "https://sepolia.base.org" + +# Native USDC on Base. See https://developer.coinbase.com/base/usdc +BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + +CHAIN_RPC_URLS: MappingProxyType[int, str] = MappingProxyType( + { + BASE_CHAIN_ID: BASE_RPC_URL, + BASE_SEPOLIA_CHAIN_ID: BASE_SEPOLIA_RPC_URL, + } +) + +DEFAULT_CURRENCIES: MappingProxyType[int, str] = MappingProxyType( + { + BASE_CHAIN_ID: BASE_USDC, + BASE_SEPOLIA_CHAIN_ID: BASE_SEPOLIA_USDC, + } +) + + +def rpc_url_for_chain(chain_id: int) -> str: + """Return the default RPC URL for a known EVM chain ID. + + Raises: + ValueError: If the chain ID is not recognized. + """ + url = CHAIN_RPC_URLS.get(chain_id) + if url is None: + raise ValueError( + f"Unknown chain_id {chain_id}. Known chains: {list(CHAIN_RPC_URLS)}. " + f"Pass rpc_url explicitly for custom chains." + ) + return url + + +def default_currency_for_chain(chain_id: int | None) -> str | None: + """Return the default USDC currency address for a known EVM chain ID. + + Returns ``None`` for unknown chains and when ``chain_id`` is ``None`` — there + is no chain-agnostic default currency to fall back on for a custom chain. + """ + if chain_id is None: + return None + return DEFAULT_CURRENCIES.get(chain_id) diff --git a/src/mpp/methods/evm/_rpc.py b/src/mpp/methods/evm/_rpc.py new file mode 100644 index 0000000..361ecd5 --- /dev/null +++ b/src/mpp/methods/evm/_rpc.py @@ -0,0 +1,32 @@ +"""Low-level JSON-RPC helper for the EVM payment method.""" + +from __future__ import annotations + +from typing import Any + +from mpp._defaults import DEFAULT_TIMEOUT + + +async def _rpc_call( + rpc_url: str, + method: str, + params: list[Any], + *, + client: Any | None = None, +) -> Any: + """Make a JSON-RPC call, raising ``RuntimeError`` on an RPC error.""" + import httpx + + payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1} + + if client is not None: + resp = await client.post(rpc_url, json=payload) + else: + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as c: + resp = await c.post(rpc_url, json=payload) + + resp.raise_for_status() + result = resp.json() + if "error" in result: + raise RuntimeError(f"RPC error: {result['error']}") + return result["result"] diff --git a/src/mpp/methods/evm/account.py b/src/mpp/methods/evm/account.py new file mode 100644 index 0000000..a994dfd --- /dev/null +++ b/src/mpp/methods/evm/account.py @@ -0,0 +1,75 @@ +"""EVM account management for signing transactions. + +Wraps eth-account for key management and signing operations. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from eth_account.signers.local import LocalAccount + + +@dataclass(frozen=True) +class EvmAccount: + """Wrapper around eth-account for signing EVM transactions. + + Example: + # From hex private key + account = EvmAccount.from_key("0x...") + + # From environment variable + account = EvmAccount.from_env("EVM_PRIVATE_KEY") + """ + + _account: LocalAccount + + @classmethod + def from_key(cls, private_key: str) -> EvmAccount: + """Load from hex private key (0x-prefixed).""" + from eth_account import Account + + return cls(_account=Account.from_key(private_key)) + + @classmethod + def from_env(cls, var: str = "EVM_PRIVATE_KEY") -> EvmAccount: + """Load from environment variable. + + Raises: + ValueError: If the environment variable is not set. + """ + key = os.environ.get(var) + if not key: + raise ValueError(f"${var} not set") + return cls.from_key(key) + + @classmethod + def from_file(cls, path: str) -> EvmAccount: + """Load from a local file containing a hex private key. + + Args: + path: Path to the key file. The file's contents are stripped of + surrounding whitespace before use. + + Raises: + ValueError: If the file is empty. + FileNotFoundError: If the file does not exist. + """ + key = Path(path).read_text().strip() + if not key: + raise ValueError(f"{path} is empty") + return cls.from_key(key) + + @property + def address(self) -> str: + """Get the account's Ethereum address.""" + return self._account.address + + @property + def private_key(self) -> str: + """Get the private key as a hex string for signing.""" + return self._account.key.hex() diff --git a/src/mpp/methods/evm/client.py b/src/mpp/methods/evm/client.py new file mode 100644 index 0000000..6c98c76 --- /dev/null +++ b/src/mpp/methods/evm/client.py @@ -0,0 +1,282 @@ +"""EVM payment method for client-side credential creation. + +Implements the charge (EvmMethod) client method: builds and signs a plain +EIP-1559 transaction calling ``transfer(address,uint256)`` on the requested +ERC-20 currency, since standard EVM chains (e.g. Base) have none of Tempo's +native extensions (memos, MACH fee tokens, escrow, access keys). +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, cast + +from mpp import Challenge, Credential +from mpp.methods import CanOfferFn, PaymentSuccessHandler +from mpp.methods.evm._defaults import ( + BASE_CHAIN_ID, + BASE_RPC_URL, + default_currency_for_chain, + rpc_url_for_chain, +) +from mpp.methods.evm._rpc import _rpc_call + +if TYPE_CHECKING: + from collections.abc import Mapping + + from mpp.methods.evm.account import EvmAccount + from mpp.server.intent import Intent, VerifiableIntent + +DEFAULT_GAS_LIMIT = 100_000 +TRANSFER_SELECTOR = "a9059cbb" +ABI_WORD_HEX_LEN = 64 + +_CHAIN_ID_UNSET = object() + + +class TransactionError(Exception): + """Transaction building or submission failed.""" + + +def _encode_transfer(to: str, amount: int) -> str: + """Encode an ERC-20 ``transfer(address,uint256)`` call. + + Selector: 0xa9059cbb = keccak256("transfer(address,uint256)")[:4] + """ + to_padded = to[2:].lower().zfill(ABI_WORD_HEX_LEN) + amount_padded = hex(amount)[2:].zfill(ABI_WORD_HEX_LEN) + return f"0x{TRANSFER_SELECTOR}{to_padded}{amount_padded}" + + +# ────────────────────────────────────────────────────────────────── +# Charge client method +# ────────────────────────────────────────────────────────────────── + + +@dataclass +class EvmMethod: + """EVM payment method implementation (e.g. Base). + + Handles client-side credential creation for EVM payments: builds and + signs a standard ERC-20 transfer transaction for the requested amount, + currency, and recipient. + + Example: + from mpp.methods.evm import evm, EvmAccount + + account = EvmAccount.from_key("0x...") + method = evm(account=account, intents={"charge": ChargeIntent()}) + + from mpp.client import get + response = await get("https://api.example.com", methods=[method]) + """ + + name: str = "evm" + account: EvmAccount | None = None + rpc_url: str = BASE_RPC_URL + chain_id: int | None = BASE_CHAIN_ID + currency: str | None = None + recipient: str | None = None + decimals: int = 6 + client_id: str | None = None + _intents: dict[str, Intent | VerifiableIntent] = field(default_factory=dict) + can_offer: CanOfferFn | None = field(default=None, kw_only=True) + on_payment_success: PaymentSuccessHandler | None = field(default=None, kw_only=True) + + @property + def intents(self) -> dict[str, Intent | VerifiableIntent]: + """Available intents for this method.""" + return self._intents + + async def create_credential(self, challenge: Challenge) -> Credential: + """Create a credential to satisfy the given charge challenge. + + Builds and signs a plain ERC-20 transfer transaction matching the + challenge's amount, currency, and recipient. + + Raises: + ValueError: If no account is configured or intent is unsupported. + TransactionError: If transaction building fails. + """ + if self.account is None: + raise ValueError("No account configured for signing") + if challenge.intent != "charge": + raise ValueError(f"Unsupported intent: {challenge.intent}") + + request = challenge.request + method_details = request.get("methodDetails", {}) + challenge_chain_id = ( + method_details.get("chainId") if isinstance(method_details, dict) else None + ) + expected_chain_id = ( + int(challenge_chain_id) if challenge_chain_id is not None else self.chain_id + ) + + raw_tx, chain_id = await self._build_transfer( + amount=request["amount"], + currency=request["currency"], + recipient=request["recipient"], + expected_chain_id=expected_chain_id, + ) + + return Credential( + challenge=challenge.to_echo(), + payload={"type": "transaction", "signature": raw_tx}, + source=f"did:pkh:eip155:{chain_id}:{self.account.address}", + ) + + async def _build_transfer( + self, + amount: str, + currency: str, + recipient: str, + expected_chain_id: int | None, + ) -> tuple[str, int]: + """Build and sign a standard EIP-1559 ERC-20 transfer transaction. + + Returns: + Tuple of (raw signed transaction hex, chain ID). + + Raises: + TransactionError: If the RPC's chain ID doesn't match expected. + """ + from eth_account import Account + + if self.account is None: + raise ValueError("No account configured") + + data = _encode_transfer(recipient, int(amount)) + + chain_id_hex, nonce_hex, priority_fee_hex, latest_block = await asyncio.gather( + _rpc_call(self.rpc_url, "eth_chainId", []), + _rpc_call(self.rpc_url, "eth_getTransactionCount", [self.account.address, "pending"]), + _rpc_call(self.rpc_url, "eth_maxPriorityFeePerGas", []), + _rpc_call(self.rpc_url, "eth_getBlockByNumber", ["latest", False]), + ) + chain_id = int(chain_id_hex, 16) + if expected_chain_id is not None and chain_id != expected_chain_id: + raise TransactionError( + f"Chain ID mismatch: RPC returned {chain_id}, " + f"expected {expected_chain_id} from client policy" + ) + + nonce = int(nonce_hex, 16) + max_priority_fee_per_gas = int(priority_fee_hex, 16) + base_fee_per_gas = int(latest_block["baseFeePerGas"], 16) + max_fee_per_gas = base_fee_per_gas * 2 + max_priority_fee_per_gas + + try: + gas_hex = await _rpc_call( + self.rpc_url, + "eth_estimateGas", + [{"from": self.account.address, "to": currency, "data": data}, "latest"], + ) + gas_limit = max(DEFAULT_GAS_LIMIT, int(gas_hex, 16) + 10_000) + except Exception: + gas_limit = DEFAULT_GAS_LIMIT + + tx = { + "chainId": chain_id, + "nonce": nonce, + "maxPriorityFeePerGas": max_priority_fee_per_gas, + "maxFeePerGas": max_fee_per_gas, + "gas": gas_limit, + "to": currency, + "value": 0, + "data": data, + "type": 2, + } + signed = Account.sign_transaction(tx, self.account.private_key) + return "0x" + signed.raw_transaction.hex(), chain_id + + +# ────────────────────────────────────────────────────────────────── +# Factory +# ────────────────────────────────────────────────────────────────── + + +def evm( + intents: Mapping[str, Intent | VerifiableIntent], + account: EvmAccount | None = None, + chain_id: int | None | object = _CHAIN_ID_UNSET, + rpc_url: str | None = None, + currency: str | None = None, + recipient: str | None = None, + decimals: int = 6, + client_id: str | None = None, + can_offer: CanOfferFn | None = None, + on_payment_success: PaymentSuccessHandler | None = None, +) -> EvmMethod: + """Create an EVM payment method (Base mainnet by default). + + Unlike :func:`mpp.methods.tempo.tempo`, this settles payments with a plain + ERC-20 ``transfer`` instead of a native account-abstraction transaction, + since standard EVM chains have none of Tempo's extensions. + + Args: + intents: Intents to register (e.g. charge). + account: Account for signing transactions (client-side). + chain_id: EVM chain ID (default: 8453 for Base mainnet, use 84532 + for Base Sepolia). Resolves the RPC URL and default USDC + currency automatically from known chains. + rpc_url: EVM JSON-RPC endpoint URL. Overrides the URL resolved from + ``chain_id``. Defaults to Base mainnet if neither is set. + currency: Default currency (ERC-20 contract address) for charges. + recipient: Default recipient address for charges. + decimals: Token decimal places for amount conversion (default: 6, + matching USDC). + client_id: Optional client identity (unused, kept for parity with + ``tempo()``). + can_offer: Optional callback that filters this method's composed offers. + on_payment_success: Optional callback invoked after successful verification. + + Returns: + A configured EvmMethod instance. + + Example: + from mpp.methods.evm import evm, ChargeIntent, EvmAccount, BASE_CHAIN_ID + + # Server + method = evm( + chain_id=BASE_CHAIN_ID, + recipient="0x...", + intents={"charge": ChargeIntent()}, + ) + + # Client + method = evm( + account=EvmAccount.from_key("0x..."), + intents={"charge": ChargeIntent()}, + ) + """ + resolved_chain_id = ( + BASE_CHAIN_ID if chain_id is _CHAIN_ID_UNSET else cast("int | None", chain_id) + ) + + if rpc_url is None: + if resolved_chain_id is None: + raise ValueError("chain_id or rpc_url is required") + rpc_url = rpc_url_for_chain(resolved_chain_id) + + if currency is None: + currency = default_currency_for_chain(resolved_chain_id) + + method = EvmMethod( + account=account, + rpc_url=rpc_url, + chain_id=resolved_chain_id, + currency=currency, + recipient=recipient, + decimals=decimals, + client_id=client_id, + can_offer=can_offer, + on_payment_success=on_payment_success, + ) + for intent in intents.values(): + if hasattr(intent, "rpc_url") and intent.rpc_url is None: # type: ignore[union-attr] + intent.rpc_url = rpc_url # type: ignore[union-attr] + if hasattr(intent, "_method"): + intent._method = method # type: ignore[union-attr] + method._intents = dict(intents) + return method diff --git a/src/mpp/methods/evm/intents.py b/src/mpp/methods/evm/intents.py new file mode 100644 index 0000000..0637fb2 --- /dev/null +++ b/src/mpp/methods/evm/intents.py @@ -0,0 +1,240 @@ +"""EVM payment intents (server-side verification). + +Implements the charge intent for standard EVM chains (e.g. Base): broadcasts +a client-signed ERC-20 transfer transaction to an EVM JSON-RPC endpoint and +confirms the mined receipt contains a matching ``Transfer`` log. +""" + +from __future__ import annotations + +import asyncio +import time +from datetime import UTC, datetime +from typing import Any + +from eth_account.typed_transactions.typed_transaction import TypedTransaction +from eth_hash.auto import keccak +from hexbytes import HexBytes + +from mpp import Credential, Receipt +from mpp._defaults import DEFAULT_TIMEOUT +from mpp.errors import VerificationError +from mpp.methods.evm._defaults import rpc_url_for_chain +from mpp.methods.evm._rpc import _rpc_call +from mpp.store import Store + +SELECTOR_HEX_LEN = 8 +ABI_WORD_HEX_LEN = 64 +TRANSFER_SELECTOR = "a9059cbb" +TRANSFER_CALL_DATA_HEX_LEN = SELECTOR_HEX_LEN + (2 * ABI_WORD_HEX_LEN) +TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + +RECEIPT_POLL_INTERVAL = 1.0 + + +class ChargeIntent: + """EVM charge intent for one-time payments. + + Verifies that a client-signed ERC-20 transfer transaction matches the + requested amount/currency/recipient, broadcasts it, and confirms the + mined receipt contains a matching ``Transfer`` log. + + When used via ``evm()``, ``rpc_url`` is read from the parent method + automatically. You can also pass ``rpc_url``/``chain_id`` directly for + standalone use. + + Example: + from mpp.methods.evm import evm, ChargeIntent, BASE_CHAIN_ID + + method = evm(chain_id=BASE_CHAIN_ID, intents={"charge": ChargeIntent()}) + + # Or standalone + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + """ + + name = "charge" + + def __init__( + self, + chain_id: int | None = None, + rpc_url: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + store: Store | None = None, + ) -> None: + """Initialize the charge intent. + + Args: + chain_id: EVM chain ID (8453 for Base mainnet, 84532 for Base + Sepolia). Resolves the RPC URL automatically. + rpc_url: EVM JSON-RPC endpoint URL. Overrides ``chain_id``. If + neither is set, will be inherited from ``evm()``. + timeout: Request timeout in seconds, also used as the deadline + for waiting on a transaction receipt. + store: Optional key-value store for tx hash replay protection. + When provided, each broadcast hash is recorded and + subsequent attempts to reuse it are rejected. + """ + if rpc_url is None and chain_id is not None: + rpc_url = rpc_url_for_chain(chain_id) + self.rpc_url = rpc_url + self._method = None + self._timeout = timeout + self._store = store + + def _get_rpc_url(self) -> str: + if self.rpc_url is None: + raise VerificationError("No rpc_url configured on ChargeIntent") + return self.rpc_url + + async def verify( + self, + credential: Credential, + request: dict[str, Any], + ) -> Receipt: + """Verify a charge credential and settle it on-chain. + + Args: + credential: The payment credential from the client. + request: The original payment request parameters. + + Returns: + A receipt for the settled payment. + + Raises: + VerificationError: If verification fails or the transaction + hash was already used. + """ + amount = int(request["amount"]) + currency = request["currency"] + recipient = request["recipient"] + + challenge_expires = credential.challenge.expires + if not challenge_expires: + raise VerificationError("Request has expired (no expires)") + expires = datetime.fromisoformat(challenge_expires.replace("Z", "+00:00")) + if expires < datetime.now(UTC): + raise VerificationError("Request has expired") + + payload = credential.payload + if not isinstance(payload, dict) or payload.get("type") != "transaction": + raise VerificationError("Invalid credential payload") + raw_tx = payload.get("signature") + if not isinstance(raw_tx, str): + raise VerificationError("Invalid credential payload") + + tx_hash = self._validate_transaction( + raw_tx, currency=currency, recipient=recipient, amount=amount + ) + + store_key = f"mpp:evm:charge:{tx_hash.lower()}" + if self._store is not None and not await self._store.put_if_absent(store_key, tx_hash): + raise VerificationError("Transaction hash already used") + + try: + receipt_data = await self._broadcast(raw_tx, tx_hash) + self._verify_receipt_transfer( + receipt_data, currency=currency, recipient=recipient, amount=amount + ) + except Exception: + if self._store is not None: + await self._store.delete(store_key) + raise + + return Receipt.success(tx_hash, method="evm") + + def _validate_transaction( + self, + raw_tx: str, + *, + currency: str, + recipient: str, + amount: int, + ) -> str: + """Decode the signed transaction and check it matches the request. + + Returns: + The transaction hash (0x-prefixed hex). + """ + try: + decoded_tx = TypedTransaction.from_bytes(HexBytes(raw_tx)) + decoded = decoded_tx.as_dict() + except Exception as err: + raise VerificationError("Invalid serialized transaction") from err + + to_address = "0x" + decoded["to"].hex() + if to_address.lower() != currency.lower(): + raise VerificationError("Invalid transaction: does not call the expected currency") + if decoded.get("value"): + raise VerificationError("Invalid transaction: must not transfer native value") + + call_data_hex = decoded["data"].hex() + if len(call_data_hex) != TRANSFER_CALL_DATA_HEX_LEN: + raise VerificationError("Invalid transaction: unexpected call data") + if call_data_hex[:SELECTOR_HEX_LEN].lower() != TRANSFER_SELECTOR: + raise VerificationError("Invalid transaction: not an ERC-20 transfer") + + decoded_to = ( + "0x" + call_data_hex[SELECTOR_HEX_LEN : SELECTOR_HEX_LEN + ABI_WORD_HEX_LEN][-40:] + ) + decoded_amount = int(call_data_hex[SELECTOR_HEX_LEN + ABI_WORD_HEX_LEN :], 16) + + if decoded_to.lower() != recipient.lower(): + raise VerificationError("Invalid transaction: recipient does not match request") + if decoded_amount != amount: + raise VerificationError("Invalid transaction: amount does not match request") + + # NOTE: TypedTransaction.hash() returns the pre-signature signing + # hash, not the on-chain transaction hash. The transaction hash used + # to identify a broadcast tx is keccak256 of the full signed, + # type-prefixed transaction bytes (EIP-2718). + return "0x" + keccak(HexBytes(raw_tx)).hex() + + async def _broadcast(self, raw_tx: str, tx_hash: str) -> dict[str, Any]: + """Submit the raw transaction and poll until it is mined.""" + rpc_url = self._get_rpc_url() + + try: + await _rpc_call(rpc_url, "eth_sendRawTransaction", [raw_tx]) + except RuntimeError as err: + message = str(err).lower() + if "already known" not in message and "nonce too low" not in message: + raise VerificationError(f"Transaction submission failed: {err}") from err + + deadline = time.monotonic() + self._timeout + while True: + receipt_data = await _rpc_call(rpc_url, "eth_getTransactionReceipt", [tx_hash]) + if receipt_data: + return receipt_data + if time.monotonic() >= deadline: + raise VerificationError("Timed out waiting for transaction receipt") + await asyncio.sleep(RECEIPT_POLL_INTERVAL) + + def _verify_receipt_transfer( + self, + receipt_data: dict[str, Any], + *, + currency: str, + recipient: str, + amount: int, + ) -> None: + if receipt_data.get("status") != "0x1": + raise VerificationError("Transaction reverted") + + for log in receipt_data.get("logs", []): + if log.get("address", "").lower() != currency.lower(): + continue + topics = log.get("topics", []) + if len(topics) < 3 or topics[0].lower() != TRANSFER_TOPIC: + continue + to_address = "0x" + topics[2][-40:] + if to_address.lower() != recipient.lower(): + continue + data = log.get("data", "0x") + if len(data) < 66: + continue + if int(data, 16) == amount: + return + + raise VerificationError( + "Transaction must contain a Transfer log matching request parameters" + ) diff --git a/tests/test_evm.py b/tests/test_evm.py new file mode 100644 index 0000000..dc735cf --- /dev/null +++ b/tests/test_evm.py @@ -0,0 +1,627 @@ +"""Tests for the EVM payment method.""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import patch + +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from mpp import Challenge +from mpp.errors import VerificationError +from mpp.methods.evm import ( + BASE_CHAIN_ID, + BASE_RPC_URL, + BASE_SEPOLIA_CHAIN_ID, + BASE_SEPOLIA_RPC_URL, + BASE_SEPOLIA_USDC, + BASE_USDC, + ChargeIntent, + EvmAccount, + EvmMethod, + TransactionError, + default_currency_for_chain, + evm, + rpc_url_for_chain, +) +from mpp.methods.evm.client import _encode_transfer +from mpp.stores import MemoryStore +from tests import make_bound_credential, make_credential + +TEST_PRIVATE_KEY = "0x" + "11" * 32 +CURRENCY = "0x" + "22" * 20 +RECIPIENT = "0x" + "33" * 20 +SENDER = "0x" + "44" * 20 + +TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + + +def amount_hex(amount: int) -> str: + return "0x" + hex(amount)[2:].zfill(64) + + +def address_topic(address: str) -> str: + return "0x" + "0" * 24 + address[2:].lower() + + +def mock_response(status_code: int = 200, json: dict | None = None) -> httpx.Response: + request = httpx.Request("POST", "https://rpc.test") + return httpx.Response(status_code, json=json, request=request) + + +def build_signed_transfer_tx( + *, + account: EvmAccount, + chain_id: int = BASE_CHAIN_ID, + currency: str = CURRENCY, + recipient: str = RECIPIENT, + amount: int = 1_000_000, + nonce: int = 0, + gas: int = 100_000, + max_fee_per_gas: int = 100, + max_priority_fee_per_gas: int = 1, + value: int = 0, + data: str | None = None, +) -> str: + """Build and sign a plain EIP-1559 ERC-20 transfer transaction for tests.""" + from eth_account import Account + + tx = { + "chainId": chain_id, + "nonce": nonce, + "maxPriorityFeePerGas": max_priority_fee_per_gas, + "maxFeePerGas": max_fee_per_gas, + "gas": gas, + "to": currency, + "value": value, + "data": data if data is not None else _encode_transfer(recipient, amount), + "type": 2, + } + signed = Account.sign_transaction(tx, account.private_key) + return "0x" + signed.raw_transaction.hex() + + +def transfer_log( + *, + currency: str = CURRENCY, + recipient: str = RECIPIENT, + amount: int = 1_000_000, + sender: str = SENDER, +) -> dict[str, Any]: + return { + "address": currency, + "topics": [TRANSFER_TOPIC, address_topic(sender), address_topic(recipient)], + "data": amount_hex(amount), + } + + +# ────────────────────────────────────────────────────────────────── +# Defaults +# ────────────────────────────────────────────────────────────────── + + +class TestDefaults: + def test_rpc_url_for_known_chain(self) -> None: + assert rpc_url_for_chain(BASE_CHAIN_ID) == BASE_RPC_URL + assert rpc_url_for_chain(BASE_SEPOLIA_CHAIN_ID) == BASE_SEPOLIA_RPC_URL + + def test_rpc_url_for_unknown_chain_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown chain_id"): + rpc_url_for_chain(999999) + + def test_default_currency_for_known_chain(self) -> None: + assert default_currency_for_chain(BASE_CHAIN_ID) == BASE_USDC + assert default_currency_for_chain(BASE_SEPOLIA_CHAIN_ID) == BASE_SEPOLIA_USDC + + def test_default_currency_for_unknown_or_none_chain(self) -> None: + assert default_currency_for_chain(None) is None + assert default_currency_for_chain(999999) is None + + +# ────────────────────────────────────────────────────────────────── +# EvmAccount +# ────────────────────────────────────────────────────────────────── + + +class TestEvmAccount: + def test_from_key(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + assert account.address.startswith("0x") + assert len(account.address) == 42 + + def test_from_env(self) -> None: + with patch.dict(os.environ, {"TEST_EVM_KEY": TEST_PRIVATE_KEY}): + account = EvmAccount.from_env("TEST_EVM_KEY") + assert account.address.startswith("0x") + + def test_from_env_missing_raises(self) -> None: + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match=r"\$EVM_PRIVATE_KEY not set"): + EvmAccount.from_env() + + def test_from_file(self, tmp_path) -> None: + key_file = tmp_path / "key" + key_file.write_text(f" {TEST_PRIVATE_KEY} \n") + account = EvmAccount.from_file(str(key_file)) + assert account.address == EvmAccount.from_key(TEST_PRIVATE_KEY).address + + def test_from_file_empty_raises(self, tmp_path) -> None: + key_file = tmp_path / "key" + key_file.write_text(" \n") + with pytest.raises(ValueError, match="is empty"): + EvmAccount.from_file(str(key_file)) + + def test_from_file_missing_raises(self, tmp_path) -> None: + with pytest.raises(FileNotFoundError): + EvmAccount.from_file(str(tmp_path / "nope")) + + def test_private_key_round_trip(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + # Re-derive an account from the exposed private key and confirm it matches. + assert EvmAccount.from_key(account.private_key).address == account.address + + +# ────────────────────────────────────────────────────────────────── +# _encode_transfer +# ────────────────────────────────────────────────────────────────── + + +def test_encode_transfer() -> None: + data = _encode_transfer(RECIPIENT, 1_000_000) + assert data.startswith("0xa9059cbb") + assert len(data) == 2 + 8 + 64 + 64 + assert data[10:74] == "0" * 24 + RECIPIENT[2:].lower() + assert int(data[74:], 16) == 1_000_000 + + +# ────────────────────────────────────────────────────────────────── +# evm() factory +# ────────────────────────────────────────────────────────────────── + + +class TestEvmFactory: + def test_defaults_to_base_mainnet(self) -> None: + method = evm(intents={}) + assert method.chain_id == BASE_CHAIN_ID + assert method.rpc_url == BASE_RPC_URL + assert method.currency == BASE_USDC + + def test_explicit_chain_id_resolves_rpc_and_currency(self) -> None: + method = evm(intents={}, chain_id=BASE_SEPOLIA_CHAIN_ID) + assert method.rpc_url == BASE_SEPOLIA_RPC_URL + assert method.currency == BASE_SEPOLIA_USDC + + def test_explicit_rpc_url_overrides_chain_default(self) -> None: + method = evm(intents={}, rpc_url="https://custom.example.com") + assert method.rpc_url == "https://custom.example.com" + # currency defaulting still follows chain_id (defaults to Base mainnet). + assert method.currency == BASE_USDC + + def test_none_chain_id_without_rpc_url_raises(self) -> None: + with pytest.raises(ValueError, match="chain_id or rpc_url is required"): + evm(intents={}, chain_id=None) + + def test_none_chain_id_with_rpc_url_has_no_default_currency(self) -> None: + method = evm(intents={}, chain_id=None, rpc_url="https://custom.example.com") + assert method.currency is None + + def test_unknown_chain_id_without_rpc_url_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown chain_id"): + evm(intents={}, chain_id=999999) + + def test_explicit_currency_is_preserved(self) -> None: + custom_currency = "0x" + "55" * 20 + method = evm(intents={}, currency=custom_currency) + assert method.currency == custom_currency + + def test_wires_rpc_url_and_method_into_intents(self) -> None: + intent = ChargeIntent() + method = evm(intents={"charge": intent}, chain_id=BASE_SEPOLIA_CHAIN_ID) + assert intent.rpc_url == BASE_SEPOLIA_RPC_URL + assert intent._method is method + assert method.intents == {"charge": intent} + + def test_does_not_override_explicit_intent_rpc_url(self) -> None: + intent = ChargeIntent(rpc_url="https://pinned.example.com") + evm(intents={"charge": intent}, chain_id=BASE_SEPOLIA_CHAIN_ID) + assert intent.rpc_url == "https://pinned.example.com" + + def test_name_and_account(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + method = evm(intents={}, account=account) + assert method.name == "evm" + assert method.account is account + + +# ────────────────────────────────────────────────────────────────── +# EvmMethod.create_credential (client-side) +# ────────────────────────────────────────────────────────────────── + + +def make_charge_challenge( + *, + amount: str = "1000000", + currency: str = CURRENCY, + recipient: str = RECIPIENT, + method_details: dict[str, Any] | None = None, +) -> Challenge: + request: dict[str, Any] = {"amount": amount, "currency": currency, "recipient": recipient} + if method_details is not None: + request["methodDetails"] = method_details + return Challenge(id="c1", method="evm", intent="charge", request=request, realm="test") + + +class TestCreateCredential: + async def test_requires_account(self) -> None: + method = EvmMethod() + with pytest.raises(ValueError, match="No account configured"): + await method.create_credential(make_charge_challenge()) + + async def test_requires_charge_intent(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + method = EvmMethod(account=account) + challenge = Challenge(id="c1", method="evm", intent="authorize", request={}, realm="test") + with pytest.raises(ValueError, match="Unsupported intent"): + await method.create_credential(challenge) + + async def test_builds_signed_credential(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + method = evm(intents={}, account=account, chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"result": hex(BASE_CHAIN_ID)}) + httpx_mock.add_response(json={"result": "0x0"}) + httpx_mock.add_response(json={"result": "0x1"}) + httpx_mock.add_response(json={"result": {"baseFeePerGas": "0x64"}}) + httpx_mock.add_response(json={"result": "0x5208"}) + + credential = await method.create_credential(make_charge_challenge()) + + assert credential.payload["type"] == "transaction" + assert isinstance(credential.payload["signature"], str) + assert credential.payload["signature"].startswith("0x") + assert credential.source == f"did:pkh:eip155:{BASE_CHAIN_ID}:{account.address}" + + async def test_gas_estimation_failure_falls_back_to_default( + self, httpx_mock: HTTPXMock + ) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + method = evm(intents={}, account=account, chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"result": hex(BASE_CHAIN_ID)}) + httpx_mock.add_response(json={"result": "0x0"}) + httpx_mock.add_response(json={"result": "0x1"}) + httpx_mock.add_response(json={"result": {"baseFeePerGas": "0x64"}}) + httpx_mock.add_response(status_code=500) + + credential = await method.create_credential(make_charge_challenge()) + assert credential.payload["signature"].startswith("0x") + + async def test_chain_id_mismatch_raises(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + method = evm(intents={}, account=account, chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"result": hex(BASE_SEPOLIA_CHAIN_ID)}) + httpx_mock.add_response(json={"result": "0x0"}) + httpx_mock.add_response(json={"result": "0x1"}) + httpx_mock.add_response(json={"result": {"baseFeePerGas": "0x64"}}) + + with pytest.raises(TransactionError, match="Chain ID mismatch"): + await method.create_credential(make_charge_challenge()) + + async def test_challenge_chain_id_overrides_expected(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + # Method is pinned to mainnet, but the challenge requests Sepolia — the + # client should validate against the challenge's chainId, not its own. + method = evm(intents={}, account=account, chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"result": hex(BASE_SEPOLIA_CHAIN_ID)}) + httpx_mock.add_response(json={"result": "0x0"}) + httpx_mock.add_response(json={"result": "0x1"}) + httpx_mock.add_response(json={"result": {"baseFeePerGas": "0x64"}}) + httpx_mock.add_response(json={"result": "0x5208"}) + + challenge = make_charge_challenge(method_details={"chainId": BASE_SEPOLIA_CHAIN_ID}) + credential = await method.create_credential(challenge) + assert credential.source == f"did:pkh:eip155:{BASE_SEPOLIA_CHAIN_ID}:{account.address}" + + +# ────────────────────────────────────────────────────────────────── +# ChargeIntent (server-side) +# ────────────────────────────────────────────────────────────────── + + +def make_charge_request( + *, + amount: str = "1000000", + currency: str = CURRENCY, + recipient: str = RECIPIENT, +) -> dict[str, Any]: + return {"amount": amount, "currency": currency, "recipient": recipient} + + +def make_expires(delta: timedelta = timedelta(hours=1)) -> str: + return (datetime.now(UTC) + delta).isoformat().replace("+00:00", "Z") + + +class TestChargeIntentValidation: + def test_requires_rpc_url(self) -> None: + intent = ChargeIntent() + with pytest.raises(VerificationError, match="No rpc_url configured"): + intent._get_rpc_url() + + def test_chain_id_resolves_rpc_url(self) -> None: + intent = ChargeIntent(chain_id=BASE_SEPOLIA_CHAIN_ID) + assert intent.rpc_url == BASE_SEPOLIA_RPC_URL + + async def test_expired_challenge(self) -> None: + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": "0xdeadbeef"}, + method="evm", + expires=make_expires(timedelta(hours=-1)), + ) + with pytest.raises(VerificationError, match="expired"): + await intent.verify(credential, make_charge_request()) + + async def test_missing_expires(self) -> None: + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": "0xdeadbeef"}, method="evm" + ) + with pytest.raises(VerificationError, match="no expires"): + await intent.verify(credential, make_charge_request()) + + async def test_invalid_payload_type(self) -> None: + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "hash", "hash": "0xabc"}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="Invalid credential payload"): + await intent.verify(credential, make_charge_request()) + + async def test_non_string_signature(self) -> None: + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": 123}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="Invalid credential payload"): + await intent.verify(credential, make_charge_request()) + + async def test_malformed_transaction(self) -> None: + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": "0xnot-a-real-tx"}, + method="evm", + expires=make_expires(), + ) + with pytest.raises(VerificationError, match="Invalid serialized transaction"): + await intent.verify(credential, make_charge_request()) + + async def test_wrong_currency(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + other_currency = "0x" + "99" * 20 + raw_tx = build_signed_transfer_tx(account=account, currency=other_currency) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="does not call the expected currency"): + await intent.verify(credential, make_charge_request()) + + async def test_nonzero_native_value(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account, value=1) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="must not transfer native value"): + await intent.verify(credential, make_charge_request()) + + async def test_wrong_call_data_length(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account, data="0xa9059cbb1234") + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="unexpected call data"): + await intent.verify(credential, make_charge_request()) + + async def test_wrong_selector(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + bad_data = "0x" + "aaaaaaaa" + "0" * 128 + raw_tx = build_signed_transfer_tx(account=account, data=bad_data) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="not an ERC-20 transfer"): + await intent.verify(credential, make_charge_request()) + + async def test_wrong_recipient_in_calldata(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account, recipient="0x" + "77" * 20) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="recipient does not match"): + await intent.verify(credential, make_charge_request()) + + async def test_wrong_amount_in_calldata(self) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account, amount=42) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + credential = make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="amount does not match"): + await intent.verify(credential, make_charge_request()) + + +class TestChargeIntentBroadcast: + def _credential(self, raw_tx: str): + return make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + + async def test_successful_charge(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": {"status": "0x1", "logs": [transfer_log()]}}) + + receipt = await intent.verify(self._credential(raw_tx), make_charge_request()) + assert receipt.status == "success" + assert receipt.method == "evm" + + async def test_reverted_transaction(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": {"status": "0x0", "logs": []}}) + + with pytest.raises(VerificationError, match="reverted"): + await intent.verify(self._credential(raw_tx), make_charge_request()) + + async def test_missing_transfer_log(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": {"status": "0x1", "logs": []}}) + + with pytest.raises(VerificationError, match="Transfer log"): + await intent.verify(self._credential(raw_tx), make_charge_request()) + + async def test_receipt_polling_retries_until_mined(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": None}) + httpx_mock.add_response(json={"result": {"status": "0x1", "logs": [transfer_log()]}}) + + with patch("asyncio.sleep", return_value=None): + receipt = await intent.verify(self._credential(raw_tx), make_charge_request()) + assert receipt.status == "success" + + async def test_timeout_waiting_for_receipt(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID, timeout=-1) + + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": None}) + + with pytest.raises(VerificationError, match="Timed out"): + await intent.verify(self._credential(raw_tx), make_charge_request()) + + async def test_already_known_submission_error_is_tolerated(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"error": {"code": -32000, "message": "already known"}}) + httpx_mock.add_response(json={"result": {"status": "0x1", "logs": [transfer_log()]}}) + + receipt = await intent.verify(self._credential(raw_tx), make_charge_request()) + assert receipt.status == "success" + + async def test_submission_error_raises(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + + httpx_mock.add_response(json={"error": {"code": -32000, "message": "insufficient funds"}}) + + with pytest.raises(VerificationError, match="Transaction submission failed"): + await intent.verify(self._credential(raw_tx), make_charge_request()) + + +class TestChargeIntentReplayProtection: + async def test_rejects_reused_transaction_hash(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + store = MemoryStore() + intent = ChargeIntent(chain_id=BASE_CHAIN_ID, store=store) + + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": {"status": "0x1", "logs": [transfer_log()]}}) + + credential = make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + first = await intent.verify(credential, make_charge_request()) + assert first.status == "success" + + with pytest.raises(VerificationError, match="already used"): + await intent.verify(credential, make_charge_request()) + + async def test_releases_reservation_on_failed_broadcast(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + raw_tx = build_signed_transfer_tx(account=account) + store = MemoryStore() + intent = ChargeIntent(chain_id=BASE_CHAIN_ID, store=store) + + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": {"status": "0x0", "logs": []}}) + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": {"status": "0x1", "logs": [transfer_log()]}}) + + credential = make_credential( + {"type": "transaction", "signature": raw_tx}, method="evm", expires=make_expires() + ) + with pytest.raises(VerificationError, match="reverted"): + await intent.verify(credential, make_charge_request()) + + # A retry after the store key was released should be allowed through again. + receipt = await intent.verify(credential, make_charge_request()) + assert receipt.status == "success" + + +# ────────────────────────────────────────────────────────────────── +# End-to-end: client-built credential verified by the server intent +# ────────────────────────────────────────────────────────────────── + + +class TestEndToEnd: + async def test_client_credential_verifies_on_server(self, httpx_mock: HTTPXMock) -> None: + account = EvmAccount.from_key(TEST_PRIVATE_KEY) + client_method = evm(intents={}, account=account, chain_id=BASE_CHAIN_ID) + + # Client-side transaction build RPCs. + httpx_mock.add_response(json={"result": hex(BASE_CHAIN_ID)}) + httpx_mock.add_response(json={"result": "0x0"}) + httpx_mock.add_response(json={"result": "0x1"}) + httpx_mock.add_response(json={"result": {"baseFeePerGas": "0x64"}}) + httpx_mock.add_response(json={"result": "0x5208"}) + + challenge = make_charge_challenge() + credential = await client_method.create_credential(challenge) + + # Server-side broadcast + receipt RPCs. + httpx_mock.add_response(json={"result": "0x" + "aa" * 32}) + httpx_mock.add_response(json={"result": {"status": "0x1", "logs": [transfer_log()]}}) + + server_credential = make_bound_credential( + credential.payload, + challenge.request, + method="evm", + source=credential.source, + ) + server_intent = ChargeIntent(chain_id=BASE_CHAIN_ID) + receipt = await server_intent.verify(server_credential, challenge.request) + assert receipt.status == "success" + assert receipt.method == "evm" diff --git a/tests/test_import_isolation.py b/tests/test_import_isolation.py index 89d9471..3579104 100644 --- a/tests/test_import_isolation.py +++ b/tests/test_import_isolation.py @@ -18,6 +18,9 @@ "mpp.methods.tempo", "mpp.methods.tempo.client", "mpp.methods.tempo.intents", + "mpp.methods.evm", + "mpp.methods.evm.client", + "mpp.methods.evm.intents", ] # Modules that must NOT be loaded as a side-effect of the above imports. diff --git a/tests/test_optional_deps.py b/tests/test_optional_deps.py index 91360da..539d58a 100644 --- a/tests/test_optional_deps.py +++ b/tests/test_optional_deps.py @@ -39,6 +39,17 @@ def test_tempo_module_import_succeeds(): assert result.returncode == 0, f"Tempo module import failed:\n{result.stderr.strip()}" +def test_evm_module_import_succeeds(): + """Importing mpp.methods.evm itself should not crash (lazy loading).""" + script = textwrap.dedent("""\ + import mpp.methods.evm + # Access a non-lazy attribute that has no external deps + print(mpp.methods.evm.BASE_CHAIN_ID) + """) + result = _run_python(script) + assert result.returncode == 0, f"EVM module import failed:\n{result.stderr.strip()}" + + def test_mcp_module_import_succeeds(): """Importing mpp.extensions.mcp itself should not crash (lazy loading).""" script = textwrap.dedent("""\ @@ -98,6 +109,50 @@ def test_tempo_lazy_attr_error_message(): assert result.stdout.strip() == "ok" +def test_evm_lazy_attr_error_message(): + """Accessing a lazy evm attr with missing deps gives a helpful message. + + Uses ChargeIntent, which imports eth-account/eth-hash/hexbytes at module + level, so blocking those reliably triggers the lazy-import guard. + """ + script = textwrap.dedent("""\ + import sys + + blocked = [ + "eth_account", "eth_account.signers", "eth_account.signers.local", + "eth_account.typed_transactions", "eth_account.typed_transactions.typed_transaction", + "eth_hash", "eth_hash.auto", + "hexbytes", + ] + for mod_name in blocked: + sys.modules.pop(mod_name, None) + sys.modules[mod_name] = None # type: ignore + + for key in list(sys.modules): + if key.startswith("mpp.methods.evm") and key != "mpp.methods.evm._defaults": + del sys.modules[key] + if "mpp.methods.evm" in sys.modules: + del sys.modules["mpp.methods.evm"] + + import mpp.methods.evm + + try: + _ = mpp.methods.evm.ChargeIntent + print("ERROR: should have raised ImportError") + sys.exit(1) + except ImportError as e: + msg = str(e) + if 'pympp[evm]' in msg: + print("ok") + else: + print(f"ERROR: missing install hint in: {msg}") + sys.exit(1) + """) + result = _run_python(script) + assert result.returncode == 0, f"Test failed:\n{result.stderr.strip()}\n{result.stdout.strip()}" + assert result.stdout.strip() == "ok" + + def test_mcp_lazy_attr_error_message(): """Accessing a lazy MCP attr with missing deps gives a helpful message.""" script = textwrap.dedent("""\