From 93117b4810c30ba6a885d1b7fe540613c936e125 Mon Sep 17 00:00:00 2001 From: Hendobox <50964581+Hendobox@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:45:01 +0100 Subject: [PATCH 1/6] feat(defi): add evm_tx_handler PR1 (quote, preview, transfer) Introduce defi/evm_tx_handler for a dedicated EVM agent wallet on Ethereum and Base. First PR ships resolve, quote, preview, transfer, balances, wallet_info, and update_preferences with YAML registries and mocked Web3 tests. Swap execute is stubbed for a follow-up PR. --- .gitignore | 3 + CONTRIBUTING.md | 1 + docs/skills/README.md | 6 + docs/skills/evm_tx_handler.md | 76 ++ skills/defi/__init__.py | 0 skills/defi/evm_tx_handler/__init__.py | 0 skills/defi/evm_tx_handler/abis.py | 51 ++ skills/defi/evm_tx_handler/card.json | 19 + .../defi/evm_tx_handler/config.yaml.example | 11 + .../defi/evm_tx_handler/data/addressbook.yaml | 4 + skills/defi/evm_tx_handler/data/chains.yaml | 18 + skills/defi/evm_tx_handler/data/tokens.yaml | 25 + skills/defi/evm_tx_handler/instructions.md | 57 ++ skills/defi/evm_tx_handler/manifest.yaml | 62 ++ skills/defi/evm_tx_handler/skill.py | 663 ++++++++++++++++++ skills/defi/evm_tx_handler/test_skill.py | 298 ++++++++ 16 files changed, 1294 insertions(+) create mode 100644 docs/skills/evm_tx_handler.md create mode 100644 skills/defi/__init__.py create mode 100644 skills/defi/evm_tx_handler/__init__.py create mode 100644 skills/defi/evm_tx_handler/abis.py create mode 100644 skills/defi/evm_tx_handler/card.json create mode 100644 skills/defi/evm_tx_handler/config.yaml.example create mode 100644 skills/defi/evm_tx_handler/data/addressbook.yaml create mode 100644 skills/defi/evm_tx_handler/data/chains.yaml create mode 100644 skills/defi/evm_tx_handler/data/tokens.yaml create mode 100644 skills/defi/evm_tx_handler/instructions.md create mode 100644 skills/defi/evm_tx_handler/manifest.yaml create mode 100644 skills/defi/evm_tx_handler/skill.py create mode 100644 skills/defi/evm_tx_handler/test_skill.py diff --git a/.gitignore b/.gitignore index 278c9fa..a073304 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Environment Variables .env +# Per-skill local config (use config.yaml.example as template) +skills/**/config.yaml + # Python __pycache__/ *.pyc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 560b438..15bb24f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -278,6 +278,7 @@ Place each skill under one top-level directory under `skills/`. Use an existing | `compliance` | Privacy, policy, regulatory guardrails | `pii_masker`, `mica_module`, `tos_evaluator` | | `data_engineering` | Datasets, generation, ETL-style tooling | `synthetic_generator` | | `finance` | Blockchain, risk, financial analysis | `wallet_screening` | +| `defi` | On-chain trading and agent wallet execution | `evm_tx_handler` | | `office` | Documents, productivity | `pdf_form_filler` | | `optimization` | Middleware, compression, efficiency | `prompt_rewriter` | diff --git a/docs/skills/README.md b/docs/skills/README.md index 85fbb45..9554ef9 100644 --- a/docs/skills/README.md +++ b/docs/skills/README.md @@ -18,6 +18,12 @@ Tools for financial analysis, blockchain interaction, and regulatory compliance. | :--- | :--- | :--- | :--- | | **[Wallet Screening](wallet_screening.md)** | `finance/wallet_screening` | [@rosspeili](https://github.com/rosspeili) ([@ARPAHLS](https://github.com/ARPAHLS)) | Comprehensive risk assessment for Ethereum wallets. Checks sanctions lists (OFAC, FBI) and identifies interactions with malicious contracts (Mixers, Scams). | +## DeFi +On-chain execution and trading for dedicated agent wallets (structured intent, previews, confirmations). + +| Skill | ID | Issuer | Description | +| :--- | :--- | :--- | :--- | +| **[EVM Transaction Handler](evm_tx_handler.md)** | `defi/evm_tx_handler` | [@Hendobox](https://github.com/Hendobox) | Quote and preview Uni V2 swaps and send transfers on Ethereum/Base from structured intent (PR1; swap execute in PR2). | ## Optimization Middleware skills that operate on text or state to increase performance, security, or efficiency. diff --git a/docs/skills/evm_tx_handler.md b/docs/skills/evm_tx_handler.md new file mode 100644 index 0000000..f32534b --- /dev/null +++ b/docs/skills/evm_tx_handler.md @@ -0,0 +1,76 @@ +# EVM Transaction Handler + +**ID**: `defi/evm_tx_handler` +**Issuer**: [@Hendobox](https://github.com/Hendobox) + +[Skill Library](README.md) · [Testing](../TESTING.md) + +Structured EVM operations for a **dedicated agent wallet**: resolve trade intent, quote Uniswap V2 swaps, preview outcomes, and send native/ERC20 transfers. **PR1** ships read/plan/transfer paths; **swap `execute`** arrives in PR2. + +## Capabilities (PR1) + +| Action | Description | +|--------|-------------| +| `resolve` | Merge intent with config and YAML registries; surface missing fields | +| `quote` / `preview` | On-chain Uni V2 quote (buy/sell) | +| `transfer` | Sign and send native or ERC20 (with optional confirmation gate) | +| `balances` | Wallet balances for registered tokens | +| `wallet_info` | Address, supported chains, preferences (no secrets) | +| `update_preferences` | Persist allowed keys to `config.yaml` | +| `execute` | Returns `not_available` until PR2 | + +## Environment + +| Variable | Required | Purpose | +| :--- | :--- | :--- | +| `AGENT_WALLET_PRIVATE_KEY` | Yes | Dedicated agent wallet (never in tool args) | +| `ETHEREUM_RPC_URL` | If using `ethereum` | JSON-RPC | +| `BASE_RPC_URL` | If using `base` | JSON-RPC | +| `COINGECKO_API_KEY` | No | Reserved for USD caps in a later release | + +Copy `skills/defi/evm_tx_handler/config.yaml.example` to `config.yaml` in the same folder for long-term defaults. See [API keys for skills](../usage/api_keys.md). + +## Registry data + +- `data/chains.yaml` — chain IDs, RPC env keys, explorers, Uni V2 routers +- `data/tokens.yaml` — symbol → contract per chain +- `data/addressbook.yaml` — label → address + +## Usage Examples + +Sample user message: *“Buy 10 DEGEN on Base with USDC”* → `resolve` → `quote` → show preview (execution in PR2). + +### Load and run + +```python +from skillware.core.loader import SkillLoader + +bundle = SkillLoader.load_skill("defi/evm_tx_handler") +skill = bundle["module"].EvmTxHandlerSkill() + +result = skill.execute({ + "action": "resolve", + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "amount": 10, + "amount_kind": "target_out", + }, +}) +``` + +Provider loops: [Agent loops](../usage/agent_loops.md), [Gemini](../usage/gemini.md), [Claude](../usage/claude.md). + +## Limitations (PR1) + +- No swap broadcast (`execute` stubbed). +- Uniswap V2 only; Ethereum + Base. +- No cross-chain bridges or aggregators. +- Create and fund the agent wallet outside the skill; key only in `.env`. + +## Security + +- Fail closed on missing RPC or registry entries. +- `confirm_before_send` blocks transfers until `confirmed: true`. +- Not financial or legal advice; agents can mis-parse NL — always preview. diff --git a/skills/defi/__init__.py b/skills/defi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/defi/evm_tx_handler/__init__.py b/skills/defi/evm_tx_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/defi/evm_tx_handler/abis.py b/skills/defi/evm_tx_handler/abis.py new file mode 100644 index 0000000..583db94 --- /dev/null +++ b/skills/defi/evm_tx_handler/abis.py @@ -0,0 +1,51 @@ +"""Minimal contract ABIs for Uniswap V2 router and ERC20.""" + +ROUTER_V2_ABI = [ + { + "name": "getAmountsOut", + "type": "function", + "stateMutability": "view", + "inputs": [ + {"name": "amountIn", "type": "uint256"}, + {"name": "path", "type": "address[]"}, + ], + "outputs": [{"name": "amounts", "type": "uint256[]"}], + }, + { + "name": "getAmountsIn", + "type": "function", + "stateMutability": "view", + "inputs": [ + {"name": "amountOut", "type": "uint256"}, + {"name": "path", "type": "address[]"}, + ], + "outputs": [{"name": "amounts", "type": "uint256[]"}], + }, +] + +ERC20_ABI = [ + { + "name": "balanceOf", + "type": "function", + "stateMutability": "view", + "inputs": [{"name": "account", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "name": "decimals", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint8"}], + }, + { + "name": "transfer", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "to", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "outputs": [{"name": "", "type": "bool"}], + }, +] diff --git a/skills/defi/evm_tx_handler/card.json b/skills/defi/evm_tx_handler/card.json new file mode 100644 index 0000000..8bd8692 --- /dev/null +++ b/skills/defi/evm_tx_handler/card.json @@ -0,0 +1,19 @@ +{ + "name": "EVM Tx Handler", + "description": "Quote and transfer from a dedicated agent wallet on Ethereum and Base.", + "issuer": { + "name": "Hendobox", + "email": "50964581+Hendobox@users.noreply.github.com", + "github": "Hendobox" + }, + "icon": "wallet", + "color": "emerald", + "ui_schema": { + "type": "card", + "fields": [ + {"key": "preview.you_pay", "label": "You pay"}, + {"key": "preview.you_receive", "label": "You receive"}, + {"key": "tx_hash", "label": "Transaction", "type": "link"} + ] + } +} diff --git a/skills/defi/evm_tx_handler/config.yaml.example b/skills/defi/evm_tx_handler/config.yaml.example new file mode 100644 index 0000000..0fb3e40 --- /dev/null +++ b/skills/defi/evm_tx_handler/config.yaml.example @@ -0,0 +1,11 @@ +# Long-term preferences (copy to config.yaml beside this file). No secrets. +default_chain: ethereum +default_spend_asset: usdc +gas_policy: normal +confirm_before_send: true +slippage_bps: 50 +max_trade_usd: 500 +private_key_env: AGENT_WALLET_PRIVATE_KEY +# Optional guardrails (omit to allow all configured chains/tokens) +# allowed_chains: [ethereum, base] +# allowed_tokens: [usdc, weth, eth] diff --git a/skills/defi/evm_tx_handler/data/addressbook.yaml b/skills/defi/evm_tx_handler/data/addressbook.yaml new file mode 100644 index 0000000..5b2f31c --- /dev/null +++ b/skills/defi/evm_tx_handler/data/addressbook.yaml @@ -0,0 +1,4 @@ +# Label (lowercase) -> checksummed address. Replace placeholders before mainnet use. +# Example only — do not send real funds to these addresses. +mom: "0x000000000000000000000000000000000000dEaD" +george: "0x000000000000000000000000000000000000dEaD" diff --git a/skills/defi/evm_tx_handler/data/chains.yaml b/skills/defi/evm_tx_handler/data/chains.yaml new file mode 100644 index 0000000..24f8cb7 --- /dev/null +++ b/skills/defi/evm_tx_handler/data/chains.yaml @@ -0,0 +1,18 @@ +# Verified Uniswap V2 Router02 deployments (re-check before mainnet use). +ethereum: + chain_id: 1 + rpc_env: ETHEREUM_RPC_URL + native_symbol: eth + native_decimals: 18 + explorer_tx_url: "https://etherscan.io/tx/{tx_hash}" + router_v2: "0x7a250d5630B4cF539739dF2C5dA4bF9C15291ECE" + weth: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + +base: + chain_id: 8453 + rpc_env: BASE_RPC_URL + native_symbol: eth + native_decimals: 18 + explorer_tx_url: "https://basescan.org/tx/{tx_hash}" + router_v2: "0x4752ba5DBC23f44d87826276bf6fd6b1c372aD24" + weth: "0x4200000000000000000000000000000000000006" diff --git a/skills/defi/evm_tx_handler/data/tokens.yaml b/skills/defi/evm_tx_handler/data/tokens.yaml new file mode 100644 index 0000000..26bc694 --- /dev/null +++ b/skills/defi/evm_tx_handler/data/tokens.yaml @@ -0,0 +1,25 @@ +# Friendly symbol (lowercase) -> contract per chain. Extend via PR; do not edit skill.py. +ethereum: + eth: + native: true + decimals: 18 + weth: + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + decimals: 18 + usdc: + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + decimals: 6 + +base: + eth: + native: true + decimals: 18 + weth: + address: "0x4200000000000000000000000000000000000006" + decimals: 18 + usdc: + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + decimals: 6 + degen: + address: "0x4ed4e862860bed51a9570b96d89af5e1b0efefed" + decimals: 18 diff --git a/skills/defi/evm_tx_handler/instructions.md b/skills/defi/evm_tx_handler/instructions.md new file mode 100644 index 0000000..9a920c3 --- /dev/null +++ b/skills/defi/evm_tx_handler/instructions.md @@ -0,0 +1,57 @@ +# EVM Transaction Handler (PR1) + +You are equipped with **`evm_tx_handler`**: a deterministic EVM tool for a **dedicated agent wallet** on Ethereum and Base. + +## Your job vs the skill's job + +| You (agent) | Skill | +|-------------|--------| +| Parse natural language into partial `intent` JSON | Merge intent with `config.yaml` and YAML registries | +| Ask for missing fields in plain language | Return `missing_fields` and `suggested_defaults` | +| Show `preview` to the user and obtain approval | Build on-chain quotes; **do not** run swaps in PR1 | +| Pass `confirmed: true` after approval | Sign and broadcast **transfers** only (PR1) | + +**Do not** expect the skill to parse free text. **Do not** pass private keys in tool arguments. + +## PR1 capabilities + +| Action | Use when | +|--------|----------| +| `resolve` | Intent is incomplete — check `missing_fields` | +| `quote` | Buy/sell intent is complete — get amounts and path | +| `preview` | Same as quote but preview-focused response | +| `execute` | **Not available in PR1** — returns `not_available`; swaps ship in PR2 | +| `transfer` | Send native ETH or ERC20 to `0x…` or addressbook label | +| `balances` | List wallet balances on a chain | +| `wallet_info` | Agent address, chains, preferences (no secrets) | +| `update_preferences` | User explicitly asks to change defaults in `config.yaml` | + +## Typical buy flow (PR1 stops before swap) + +1. User: “Buy 10 Degen on Base with USDC.” +2. `resolve` with `{ "side": "buy", "chain": "base", "target_asset": "degen", "amount": 10, "amount_kind": "target_out" }`. +3. If `spend_asset` missing, ask user; suggest `suggested_defaults.spend_asset`. +4. `quote` then show `preview` (you pay / you receive, rate, gas, warnings). +5. Tell user swap execution is **not enabled yet**; PR2 will add `execute` with `confirmed: true`. + +## Transfer flow + +1. `resolve` or build intent: `chain`, `target_asset`, `amount`, `recipient` (label or address). +2. `transfer` with `confirmed: false` first if `confirm_before_send` — skill returns `needs_confirmation`. +3. After user approves, `transfer` with `confirmed: true`. + +## Intent fields + +- `side`: `buy` | `sell` (transfers use action `transfer`, not `side: send` in quotes) +- `chain`: `ethereum` | `base` (default from config) +- `target_asset` / `spend_asset`: symbols from `data/tokens.yaml` +- `amount` + `amount_kind`: `target_out` for “buy 10 DEGEN”; `spend_in` for “sell 100 DEGEN” +- `recipient`: addressbook label or `0x` address +- `gas_policy`: `low` | `normal` | `high` | `aggressive` (per-tx only; does not persist) + +## Safety + +- Always preview transfers (recipient resolved, amount, chain). +- Warn that agents can mis-parse names and amounts. +- Recommend `finance/wallet_screening` for unknown recipient addresses before large sends. +- Respect `max_trade_usd` when enabled (enforced in PR2 quotes/execute). diff --git a/skills/defi/evm_tx_handler/manifest.yaml b/skills/defi/evm_tx_handler/manifest.yaml new file mode 100644 index 0000000..ede2ef0 --- /dev/null +++ b/skills/defi/evm_tx_handler/manifest.yaml @@ -0,0 +1,62 @@ +name: evm_tx_handler +version: 0.1.0 +description: | + Operates a dedicated EVM agent wallet for structured buy/sell quotes, previews, + and transfers on Ethereum and Base. PR1: resolve, quote, preview, transfer, + balances, wallet_info. Swap execution (execute) follows in PR2. +short_description: "EVM agent wallet — quote, preview, and transfer from structured intent." +category: defi +issuer: + name: Hendobox + email: 50964581+Hendobox@users.noreply.github.com + github: Hendobox +parameters: + type: object + properties: + action: + type: string + enum: + - resolve + - quote + - preview + - execute + - transfer + - balances + - wallet_info + - update_preferences + description: Skill operation to run. + intent: + type: object + description: | + Partial or full trade/send intent (side, chain, assets, amount, recipient, gas_policy). + confirmed: + type: boolean + description: Required true for transfer when confirm_before_send is enabled. + preferences: + type: object + description: Long-term preference updates (update_preferences only). + required: + - action +constitution: | + 1. DEDICATED WALLET ONLY: Use a separate agent wallet funded with limited funds — never a personal or treasury wallet. + 2. NOT FINANCIAL ADVICE: Quotes and transfers are technical operations, not investment recommendations. + 3. AGENT ERROR RISK: Natural language may be mis-parsed; always preview amounts and addresses before confirmed transfers. + 4. SECRETS: Private keys live only in .env (AGENT_WALLET_PRIVATE_KEY). Never pass keys in tool args, YAML, or logs. + 5. FAIL CLOSED: Missing RPC, registry entries, or confirmation when required must abort — do not guess. + 6. MARKET RISK: Slippage and prices move; on-chain results may differ from quotes. +env_vars: + AGENT_WALLET_PRIVATE_KEY: + description: "Private key for the dedicated agent wallet (hex, with or without 0x prefix)." + required: true + ETHEREUM_RPC_URL: + description: "JSON-RPC URL for Ethereum mainnet (required when using chain ethereum)." + required: false + BASE_RPC_URL: + description: "JSON-RPC URL for Base (required when using chain base)." + required: false + COINGECKO_API_KEY: + description: "Optional; reserved for USD estimates in later releases." + required: false +requirements: + - web3>=6.0.0 + - pyyaml diff --git a/skills/defi/evm_tx_handler/skill.py b/skills/defi/evm_tx_handler/skill.py new file mode 100644 index 0000000..f5ff851 --- /dev/null +++ b/skills/defi/evm_tx_handler/skill.py @@ -0,0 +1,663 @@ +""" +EVM transaction handler — PR1: resolve, quote, preview, transfer, balances, +wallet_info, update_preferences. Swap execute() ships in PR2. +""" + +from __future__ import annotations + +import copy +import os +import re +import sys +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, List, Optional, Tuple + +import yaml +from eth_account import Account +from web3 import Web3 +from web3.contract import Contract + +from skillware.core.base_skill import BaseSkill + +_SKILL_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SKILL_DIR not in sys.path: + sys.path.insert(0, _SKILL_DIR) +from abis import ERC20_ABI, ROUTER_V2_ABI # noqa: E402 + +_ETH_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") +_SENSITIVE_ENV_SUFFIXES = ("PRIVATE_KEY", "SECRET", "MNEMONIC") +_PREFERENCE_KEYS = frozenset( + { + "default_chain", + "default_spend_asset", + "gas_policy", + "confirm_before_send", + "slippage_bps", + "max_trade_usd", + "allowed_chains", + "allowed_tokens", + "private_key_env", + } +) +_GAS_MULTIPLIERS = { + "low": (0.95, 1.0), + "normal": (1.1, 1.5), + "high": (1.25, 2.5), + "aggressive": (1.5, 3.0), +} + + +class EvmTxHandlerSkill(BaseSkill): + """Structured EVM buy/sell quotes and transfers for a dedicated agent wallet.""" + + def __init__(self, config: Optional[Dict[str, Any]] = None): + super().__init__(config or {}) + self._skill_dir = os.path.dirname(os.path.abspath(__file__)) + self._data_dir = os.path.join(self._skill_dir, "data") + self.chains = self._load_yaml("chains.yaml") + self.tokens = self._load_yaml("tokens.yaml") + self.addressbook = self._load_yaml("addressbook.yaml") + self.user_config = self._load_user_config() + self._web3_cache: Dict[str, Web3] = {} + + @property + def manifest(self) -> Dict[str, Any]: + path = os.path.join(self._skill_dir, "manifest.yaml") + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + return {} + + def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: + action = (params.get("action") or "").strip().lower() + intent = params.get("intent") or {} + if not isinstance(intent, dict): + return self._error("intent must be an object") + + handlers = { + "resolve": self._action_resolve, + "quote": self._action_quote, + "preview": self._action_preview, + "execute": self._action_execute, + "transfer": self._action_transfer, + "balances": self._action_balances, + "wallet_info": self._action_wallet_info, + "update_preferences": self._action_update_preferences, + } + if action not in handlers: + return self._error( + f"Unknown action {action!r}. " + f"Use one of: {', '.join(sorted(handlers))}." + ) + + try: + return handlers[action](intent, params) + except ValueError as exc: + return self._error(str(exc)) + except Exception as exc: + return self._error(self._safe_error_message(exc)) + + # --- Config & registry --- + + def _load_yaml(self, name: str) -> Dict[str, Any]: + path = os.path.join(self._data_dir, name) + if not os.path.exists(path): + return {} + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + return data if isinstance(data, dict) else {} + + def _load_user_config(self) -> Dict[str, Any]: + defaults = { + "default_chain": "ethereum", + "default_spend_asset": "usdc", + "gas_policy": "normal", + "confirm_before_send": True, + "slippage_bps": 50, + "private_key_env": "AGENT_WALLET_PRIVATE_KEY", + } + path = os.path.join(self._skill_dir, "config.yaml") + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + loaded = yaml.safe_load(f) or {} + if isinstance(loaded, dict): + defaults.update(loaded) + return defaults + + def _save_user_config(self, updates: Dict[str, Any]) -> None: + merged = copy.deepcopy(self.user_config) + merged.update(updates) + path = os.path.join(self._skill_dir, "config.yaml") + with open(path, "w", encoding="utf-8") as f: + yaml.safe_dump(merged, f, default_flow_style=False, sort_keys=False) + self.user_config = merged + + def _chain_key(self, chain: Optional[str]) -> str: + key = (chain or self.user_config.get("default_chain") or "ethereum").strip().lower() + if key not in self.chains: + raise ValueError(f"Unknown chain {key!r}. Supported: {', '.join(self.chains)}.") + allowed = self.user_config.get("allowed_chains") + if allowed and key not in [c.lower() for c in allowed]: + raise ValueError(f"Chain {key!r} is not in allowed_chains.") + return key + + def _token_allowed(self, symbol: str) -> None: + allowed = self.user_config.get("allowed_tokens") + if allowed and symbol.lower() not in [t.lower() for t in allowed]: + raise ValueError(f"Token {symbol!r} is not in allowed_tokens.") + + def _resolve_token_meta(self, chain: str, symbol: str) -> Dict[str, Any]: + sym = symbol.strip().lower() + self._token_allowed(sym) + chain_tokens = self.tokens.get(chain) or {} + if sym not in chain_tokens: + raise ValueError(f"Token {sym!r} not in tokens.yaml for chain {chain!r}.") + meta = dict(chain_tokens[sym]) + meta["symbol"] = sym + meta["chain"] = chain + if meta.get("native"): + meta["address"] = None + elif meta.get("address"): + meta["address"] = Web3.to_checksum_address(meta["address"]) + return meta + + def _resolve_recipient(self, recipient: str) -> str: + raw = recipient.strip() + if _ETH_ADDRESS_RE.match(raw): + return Web3.to_checksum_address(raw) + label = raw.lower() + if label in self.addressbook: + return Web3.to_checksum_address(str(self.addressbook[label])) + raise ValueError( + f"Recipient {recipient!r} is not a valid address or addressbook label." + ) + + def _rpc_url(self, chain: str) -> str: + chain_cfg = self.chains[chain] + env_key = chain_cfg.get("rpc_env") + if not env_key: + raise ValueError(f"chains.yaml missing rpc_env for {chain!r}.") + url = os.environ.get(env_key) or (self.config or {}).get(env_key) + if not url: + raise ValueError(f"Missing RPC: set environment variable {env_key}.") + return url + + def _get_web3(self, chain: str) -> Web3: + if chain not in self._web3_cache: + self._web3_cache[chain] = Web3(Web3.HTTPProvider(self._rpc_url(chain))) + return self._web3_cache[chain] + + def _private_key_env(self) -> str: + return str(self.user_config.get("private_key_env") or "AGENT_WALLET_PRIVATE_KEY") + + def _account(self): + env_name = self._private_key_env() + key = os.environ.get(env_name) or (self.config or {}).get(env_name) + if not key: + raise ValueError( + f"Missing dedicated agent wallet key: set {env_name} in .env " + "(never pass private keys in tool arguments)." + ) + if key.startswith("0x"): + key = key[2:] + return Account.from_key(key) + + def _wallet_address(self) -> str: + return self._account().address + + # --- Intent merge / resolve --- + + def _merge_intent(self, intent: Dict[str, Any]) -> Dict[str, Any]: + merged: Dict[str, Any] = {} + chain = self._chain_key(intent.get("chain")) + merged["chain"] = chain + merged["gas_policy"] = ( + intent.get("gas_policy") or self.user_config.get("gas_policy") or "normal" + ).lower() + if merged["gas_policy"] not in _GAS_MULTIPLIERS: + raise ValueError(f"Invalid gas_policy {merged['gas_policy']!r}.") + + side = (intent.get("side") or "").strip().lower() + if side: + merged["side"] = side + + for key in ( + "target_asset", + "spend_asset", + "amount", + "amount_kind", + "recipient", + "slippage_bps", + ): + if intent.get(key) is not None: + merged[key] = intent[key] + + if intent.get("slippage_bps") is None and self.user_config.get("slippage_bps") is not None: + merged.setdefault("slippage_bps", self.user_config["slippage_bps"]) + + if side in ("buy", "sell"): + if not merged.get("target_asset"): + raise ValueError("target_asset is required for buy/sell.") + merged["target_asset"] = str(merged["target_asset"]).lower() + if merged.get("spend_asset"): + merged["spend_asset"] = str(merged["spend_asset"]).lower() + if not merged.get("amount_kind"): + merged["amount_kind"] = "target_out" if side == "buy" else "spend_in" + elif merged.get("target_asset"): + merged["target_asset"] = str(merged["target_asset"]).lower() + if merged.get("recipient"): + merged["recipient"] = str(merged["recipient"]).strip() + + return merged + + def _missing_for_trade(self, resolved: Dict[str, Any]) -> List[str]: + missing = [] + if not resolved.get("spend_asset"): + missing.append("spend_asset") + if resolved.get("amount") is None: + missing.append("amount") + return missing + + def _suggested_defaults(self, resolved: Dict[str, Any], missing: List[str]) -> Dict[str, Any]: + suggestions: Dict[str, Any] = {} + if "spend_asset" in missing: + side = resolved.get("side") + if side == "buy": + suggestions["spend_asset"] = self.user_config.get("default_spend_asset", "usdc") + elif side == "sell": + suggestions["spend_asset"] = self.user_config.get("default_spend_asset", "usdc") + return suggestions + + def _action_resolve(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + resolved = self._merge_intent(intent) + side = resolved.get("side") + + if side in ("buy", "sell"): + missing = self._missing_for_trade(resolved) + if missing: + return { + "status": "needs_input", + "resolved": resolved, + "missing_fields": missing, + "suggested_defaults": self._suggested_defaults(resolved, missing), + "agent_hint": ( + f"Ask which token to pay with; suggest " + f"{self._suggested_defaults(resolved, missing).get('spend_asset', 'usdc').upper()} " + f"from config." + if "spend_asset" in missing + else "Ask for the trade amount." + ), + } + self._resolve_token_meta(resolved["chain"], resolved["target_asset"]) + self._resolve_token_meta(resolved["chain"], resolved["spend_asset"]) + return {"status": "ready", "resolved": resolved} + + if side == "send" or intent.get("recipient") or _params.get("action") == "transfer": + missing = [] + if not resolved.get("target_asset"): + missing.append("target_asset") + if resolved.get("amount") is None: + missing.append("amount") + if not resolved.get("recipient") and not intent.get("recipient"): + missing.append("recipient") + if missing: + return { + "status": "needs_input", + "resolved": resolved, + "missing_fields": missing, + "suggested_defaults": {}, + "agent_hint": "Ask for token, amount, and recipient (address or addressbook label).", + } + return {"status": "ready", "resolved": resolved} + + return self._error("intent.side must be buy, sell, or use transfer action for sends.") + + # --- Quote / preview --- + + def _swap_path( + self, chain: str, token_in: Dict[str, Any], token_out: Dict[str, Any] + ) -> List[str]: + weth = Web3.to_checksum_address(self.chains[chain]["weth"]) + + def addr(meta: Dict[str, Any]) -> str: + if meta.get("native"): + return weth + return meta["address"] + + a_in, a_out = addr(token_in), addr(token_out) + if a_in == a_out: + raise ValueError("Cannot swap an asset into itself.") + if a_in == weth or a_out == weth: + return [a_in, a_out] + return [a_in, weth, a_out] + + def _to_wei(self, amount: float, decimals: int) -> int: + quant = Decimal(str(amount)).quantize( + Decimal(10) ** -decimals, rounding=ROUND_DOWN + ) + return int(quant * (10**decimals)) + + def _from_wei(self, wei: int, decimals: int) -> str: + value = Decimal(wei) / Decimal(10**decimals) + return format(value.normalize(), "f") + + def _router(self, w3: Web3, chain: str) -> Contract: + router_addr = Web3.to_checksum_address(self.chains[chain]["router_v2"]) + return w3.eth.contract(address=router_addr, abi=ROUTER_V2_ABI) + + def _trade_assets(self, resolved: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: + chain = resolved["chain"] + side = resolved["side"] + target = self._resolve_token_meta(chain, resolved["target_asset"]) + spend = self._resolve_token_meta(chain, resolved["spend_asset"]) + if side == "buy": + return spend, target + return target, spend + + def _build_quote(self, resolved: Dict[str, Any]) -> Dict[str, Any]: + chain = resolved["chain"] + w3 = self._get_web3(chain) + token_in, token_out = self._trade_assets(resolved) + path = self._swap_path(chain, token_in, token_out) + router = self._router(w3, chain) + amount_kind = (resolved.get("amount_kind") or "target_out").lower() + amount = float(resolved["amount"]) + slippage_bps = int(resolved.get("slippage_bps") or self.user_config.get("slippage_bps") or 50) + + if amount_kind == "target_out": + amount_out_wei = self._to_wei(amount, token_out["decimals"]) + amounts = router.functions.getAmountsIn(amount_out_wei, path).call() + amount_in_wei = amounts[0] + amount_out_wei = amounts[-1] + elif amount_kind == "spend_in": + amount_in_wei = self._to_wei(amount, token_in["decimals"]) + amounts = router.functions.getAmountsOut(amount_in_wei, path).call() + amount_out_wei = amounts[-1] + else: + raise ValueError("amount_kind must be target_out or spend_in.") + + min_out_wei = amount_out_wei * (10_000 - slippage_bps) // 10_000 + deadline = int(time.time()) + 1200 + gas = self._estimate_gas(w3, resolved.get("gas_policy", "normal")) + + return { + "path": path, + "token_in": token_in, + "token_out": token_out, + "amount_in_wei": str(amount_in_wei), + "amount_out_wei": str(amount_out_wei), + "min_out_wei": str(min_out_wei), + "deadline": deadline, + "slippage_bps": slippage_bps, + "gas_estimate": gas, + "side": resolved["side"], + "chain": chain, + } + + def _preview_from_quote(self, quote: Dict[str, Any]) -> Dict[str, Any]: + tin, tout = quote["token_in"], quote["token_out"] + pay_amt = self._from_wei(int(quote["amount_in_wei"]), tin["decimals"]) + recv_amt = self._from_wei(int(quote["amount_out_wei"]), tout["decimals"]) + rate = "" + if Decimal(pay_amt) > 0: + rate = ( + f"1 {tin['symbol']} = " + f"{format(Decimal(recv_amt) / Decimal(pay_amt), 'f')} {tout['symbol']}" + ) + + return { + "side": quote["side"], + "chain": quote["chain"], + "you_pay": {"asset": tin["symbol"], "amount": pay_amt}, + "you_receive": {"asset": tout["symbol"], "amount": recv_amt}, + "rate": rate, + "gas_estimate": quote["gas_estimate"], + "router": "uniswap_v2", + "warnings": [ + "Rates are indicative; slippage may apply.", + "Swap execution is not enabled in PR1.", + ], + } + + def _action_quote(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + resolved = self._merge_intent(intent) + if resolved.get("side") not in ("buy", "sell"): + return self._error("quote requires intent.side buy or sell.") + missing = self._missing_for_trade(resolved) + if missing: + return self._error(f"Cannot quote; missing fields: {', '.join(missing)}.") + + quote = self._build_quote(resolved) + preview = self._preview_from_quote(quote) + confirm = bool(self.user_config.get("confirm_before_send", True)) + return { + "status": "ready", + "preview": preview, + "quote": { + "path": quote["path"], + "amount_in_wei": quote["amount_in_wei"], + "amount_out_wei": quote["amount_out_wei"], + "min_out_wei": quote["min_out_wei"], + "deadline": quote["deadline"], + }, + "requires_confirmation": confirm, + } + + def _action_preview(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: + result = self._action_quote(intent, params) + if result.get("status") != "ready": + return result + return { + "status": "ready", + "preview": result["preview"], + "requires_confirmation": result.get("requires_confirmation", True), + } + + def _action_execute(self, _intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: + need_confirm = self._require_confirmed(params) + if need_confirm: + return need_confirm + return { + "status": "not_available", + "code": "pr2_execute", + "message": ( + "Swap execution (execute) ships in PR2. Use quote/preview to plan trades; " + "use transfer for sends in PR1." + ), + } + + # --- Transfer --- + + def _require_confirmed(self, params: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if self.user_config.get("confirm_before_send", True) and not params.get("confirmed"): + return { + "status": "needs_confirmation", + "message": "Set confirmed: true after the user approves this transaction.", + } + return None + + def _estimate_gas(self, w3: Web3, policy: str) -> Dict[str, Any]: + policy = policy if policy in _GAS_MULTIPLIERS else "normal" + base_fee = w3.eth.gas_price + max_mult, tip_gwei = _GAS_MULTIPLIERS[policy] + max_fee = int(base_fee * max_mult) + priority = w3.to_wei(tip_gwei, "gwei") + return { + "policy": policy, + "max_fee_gwei": str(Decimal(max_fee) / Decimal(10**9)), + "max_priority_fee_gwei": str(Decimal(priority) / Decimal(10**9)), + } + + def _eip1559_fees(self, w3: Web3, policy: str) -> Tuple[int, int]: + policy = policy if policy in _GAS_MULTIPLIERS else "normal" + base_fee = w3.eth.gas_price + max_mult, tip_gwei = _GAS_MULTIPLIERS[policy] + return int(base_fee * max_mult), w3.to_wei(tip_gwei, "gwei") + + def _sign_and_send(self, w3: Web3, tx: Dict[str, Any]) -> str: + signed = self._account().sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + return tx_hash.hex() + + def _wait_receipt(self, w3: Web3, tx_hash: str, timeout: int = 120) -> Dict[str, Any]: + try: + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout) + return { + "block_number": receipt["blockNumber"], + "gas_used": receipt["gasUsed"], + "success": receipt["status"] == 1, + } + except Exception: + return {"success": None, "note": "Receipt not confirmed within timeout."} + + def _explorer_url(self, chain: str, tx_hash: str) -> str: + template = self.chains[chain].get("explorer_tx_url", "") + return template.format(tx_hash=tx_hash) + + def _action_transfer(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: + need_confirm = self._require_confirmed(params) + if need_confirm: + return need_confirm + + resolved = self._merge_intent({**intent, "side": "send"}) + chain = resolved["chain"] + if not resolved.get("target_asset"): + return self._error("target_asset is required for transfer.") + if resolved.get("amount") is None: + return self._error("amount is required for transfer.") + if not resolved.get("recipient"): + return self._error("recipient is required for transfer.") + + recipient = self._resolve_recipient(str(resolved["recipient"])) + token = self._resolve_token_meta(chain, str(resolved["target_asset"])) + w3 = self._get_web3(chain) + amount_wei = self._to_wei(float(resolved["amount"]), token["decimals"]) + policy = resolved.get("gas_policy", "normal") + + account = self._account() + chain_id = int(self.chains[chain]["chain_id"]) + max_fee, priority = self._eip1559_fees(w3, policy) + base_tx: Dict[str, Any] = { + "from": account.address, + "chainId": chain_id, + "nonce": w3.eth.get_transaction_count(account.address), + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": priority, + } + + if token.get("native"): + tx = { + **base_tx, + "to": recipient, + "value": amount_wei, + "gas": 21_000, + } + else: + contract = w3.eth.contract(address=token["address"], abi=ERC20_ABI) + tx = contract.functions.transfer(recipient, amount_wei).build_transaction(base_tx) + + tx_hash = self._sign_and_send(w3, tx) + receipt = self._wait_receipt(w3, tx_hash) + return { + "status": "confirmed", + "tx_hash": tx_hash, + "explorer_url": self._explorer_url(chain, tx_hash), + "recipient_resolved": recipient, + "receipt": receipt, + } + + # --- Read-only --- + + def _action_balances(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + chain = self._chain_key(intent.get("chain")) + w3 = self._get_web3(chain) + address = self._wallet_address() + chain_tokens = self.tokens.get(chain) or {} + balances: Dict[str, str] = {} + + native_bal = w3.eth.get_balance(address) + balances["eth"] = self._from_wei(native_bal, 18) + + for symbol, meta in chain_tokens.items(): + if meta.get("native"): + continue + contract = w3.eth.contract( + address=Web3.to_checksum_address(meta["address"]), abi=ERC20_ABI + ) + raw = contract.functions.balanceOf(address).call() + balances[symbol] = self._from_wei(raw, int(meta["decimals"])) + + return {"status": "ready", "chain": chain, "address": address, "balances": balances} + + def _action_wallet_info(self, _intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + try: + address = self._wallet_address() + except ValueError as exc: + return {"status": "error", "message": str(exc)} + + prefs = { + k: self.user_config[k] + for k in ( + "default_chain", + "default_spend_asset", + "gas_policy", + "confirm_before_send", + "slippage_bps", + "max_trade_usd", + ) + if k in self.user_config + } + return { + "status": "ready", + "address": address, + "supported_chains": list(self.chains.keys()), + "preferences": prefs, + "capabilities": { + "resolve": True, + "quote": True, + "preview": True, + "transfer": True, + "balances": True, + "execute": False, + }, + } + + def _action_update_preferences( + self, intent: Dict[str, Any], params: Dict[str, Any] + ) -> Dict[str, Any]: + updates = params.get("preferences") or intent.get("preferences") or {} + if not isinstance(updates, dict) or not updates: + return self._error("preferences object with fields to update is required.") + + invalid = [k for k in updates if k not in _PREFERENCE_KEYS] + if invalid: + return self._error(f"Cannot update unknown preference keys: {', '.join(invalid)}.") + + if "gas_policy" in updates and updates["gas_policy"] not in _GAS_MULTIPLIERS: + return self._error("Invalid gas_policy in preferences.") + + self._save_user_config(updates) + return {"status": "updated", "preferences": copy.deepcopy(self.user_config)} + + # --- Helpers --- + + @staticmethod + def _error(message: str) -> Dict[str, Any]: + return {"status": "error", "message": message} + + def _safe_error_message(self, exc: Exception) -> str: + text = str(exc) + for key, value in os.environ.items(): + if not value or len(value) < 8: + continue + if any(s in key.upper() for s in _SENSITIVE_ENV_SUFFIXES): + text = text.replace(value, "[REDACTED]") + if value.startswith("0x"): + text = text.replace(value[2:], "[REDACTED]") + cfg_key = (self.config or {}).get(self._private_key_env()) + if cfg_key and len(str(cfg_key)) >= 8: + text = text.replace(str(cfg_key), "[REDACTED]") + if len(text) > 500: + text = text[:500] + "..." + return text diff --git a/skills/defi/evm_tx_handler/test_skill.py b/skills/defi/evm_tx_handler/test_skill.py new file mode 100644 index 0000000..31750c3 --- /dev/null +++ b/skills/defi/evm_tx_handler/test_skill.py @@ -0,0 +1,298 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from skillware.core.loader import SkillLoader + +from .skill import EvmTxHandlerSkill + +TEST_KEY = "0x" + "11" * 32 + + +@pytest.fixture +def skill(tmp_path, monkeypatch): + monkeypatch.setenv("AGENT_WALLET_PRIVATE_KEY", TEST_KEY) + monkeypatch.setenv("ETHEREUM_RPC_URL", "http://localhost:8545") + monkeypatch.setenv("BASE_RPC_URL", "http://localhost:8546") + return EvmTxHandlerSkill() + + +@pytest.fixture +def manifest(): + path = os.path.join(os.path.dirname(__file__), "manifest.yaml") + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def test_manifest_consistency(skill, manifest): + assert skill.manifest["name"] == manifest["name"] + + +def test_loader_loads_skill(monkeypatch): + monkeypatch.setenv("AGENT_WALLET_PRIVATE_KEY", TEST_KEY) + bundle = SkillLoader.load_skill("defi/evm_tx_handler") + assert bundle["manifest"]["name"] == "evm_tx_handler" + cls = bundle["module"].EvmTxHandlerSkill + instance = cls() + assert instance.execute({"action": "wallet_info", "intent": {}})["status"] == "ready" + + +def test_resolve_missing_spend_asset(skill): + result = skill.execute( + { + "action": "resolve", + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "amount": 10, + "amount_kind": "target_out", + }, + } + ) + assert result["status"] == "needs_input" + assert "spend_asset" in result["missing_fields"] + assert result["suggested_defaults"]["spend_asset"] == "usdc" + + +@patch.object(EvmTxHandlerSkill, "_get_web3") +def test_quote_buy(mock_web3, skill): + w3 = MagicMock() + w3.eth.gas_price = 10**9 + w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + mock_web3.return_value = w3 + + router = MagicMock() + router.functions.getAmountsIn.return_value.call.return_value = [100_000_000, 10_000_000_000_000_000_000] + w3.eth.contract.return_value = router + + result = skill.execute( + { + "action": "quote", + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", + }, + } + ) + assert result["status"] == "ready" + assert result["preview"]["you_pay"]["asset"] == "usdc" + assert result["preview"]["you_receive"]["asset"] == "degen" + assert "quote" in result + assert result["requires_confirmation"] is True + + +@patch.object(EvmTxHandlerSkill, "_get_web3") +def test_quote_sell(mock_web3, skill): + w3 = MagicMock() + w3.eth.gas_price = 10**9 + w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + mock_web3.return_value = w3 + + router = MagicMock() + router.functions.getAmountsOut.return_value.call.return_value = [10**18, 50_000_000] + w3.eth.contract.return_value = router + + result = skill.execute( + { + "action": "quote", + "intent": { + "side": "sell", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 1, + "amount_kind": "spend_in", + }, + } + ) + assert result["status"] == "ready" + assert result["preview"]["side"] == "sell" + + +def test_execute_not_available_pr1(skill): + result = skill.execute( + { + "action": "execute", + "intent": {"side": "buy", "chain": "base", "target_asset": "degen", "spend_asset": "usdc"}, + "confirmed": True, + } + ) + assert result["status"] == "not_available" + assert result["code"] == "pr2_execute" + + +def test_execute_needs_confirmation_when_enabled(skill): + result = skill.execute( + { + "action": "execute", + "intent": {"side": "buy", "chain": "base", "target_asset": "degen", "spend_asset": "usdc"}, + "confirmed": False, + } + ) + assert result["status"] == "needs_confirmation" + + +def test_missing_rpc_fail_closed(skill, monkeypatch): + monkeypatch.delenv("BASE_RPC_URL", raising=False) + result = skill.execute( + { + "action": "balances", + "intent": {"chain": "base"}, + } + ) + assert result["status"] == "error" + assert "BASE_RPC_URL" in result["message"] + + +def test_update_preferences_rejects_unknown_keys(skill): + result = skill.execute( + { + "action": "update_preferences", + "preferences": {"not_a_real_key": True}, + } + ) + assert result["status"] == "error" + assert "not_a_real_key" in result["message"] + + +def test_resolve_ready_buy(skill): + result = skill.execute( + { + "action": "resolve", + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", + }, + } + ) + assert result["status"] == "ready" + assert result["resolved"]["spend_asset"] == "usdc" + + +def test_transfer_requires_confirmation(skill): + result = skill.execute( + { + "action": "transfer", + "intent": { + "chain": "base", + "target_asset": "usdc", + "amount": 10, + "recipient": "mom", + }, + } + ) + assert result["status"] == "needs_confirmation" + + +@patch.object(EvmTxHandlerSkill, "_wait_receipt") +@patch.object(EvmTxHandlerSkill, "_sign_and_send") +@patch.object(EvmTxHandlerSkill, "_get_web3") +def test_transfer_resolves_addressbook(mock_web3, mock_send, mock_receipt, skill): + w3 = MagicMock() + w3.eth.gas_price = 10**9 + w3.eth.get_transaction_count.return_value = 0 + w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + mock_web3.return_value = w3 + mock_send.return_value = "0xabc" + mock_receipt.return_value = {"block_number": 1, "gas_used": 21000, "success": True} + + contract = MagicMock() + contract.functions.transfer.return_value.build_transaction.return_value = { + "to": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "gas": 100000, + } + w3.eth.contract.return_value = contract + + result = skill.execute( + { + "action": "transfer", + "confirmed": True, + "intent": { + "chain": "base", + "target_asset": "usdc", + "amount": 10, + "recipient": "mom", + }, + } + ) + assert result["status"] == "confirmed" + assert result["recipient_resolved"] == "0x000000000000000000000000000000000000dEaD" + mock_send.assert_called_once() + + +def test_gas_override_does_not_mutate_config(skill, tmp_path): + config_path = os.path.join(skill._skill_dir, "config.yaml") + original_gas = skill.user_config.get("gas_policy") + skill.execute( + { + "action": "resolve", + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 1, + "gas_policy": "aggressive", + }, + } + ) + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8") as f: + on_disk = yaml.safe_load(f) + assert on_disk.get("gas_policy") == original_gas + assert skill.user_config.get("gas_policy") == original_gas + + +def test_update_preferences(skill, tmp_path): + config_path = os.path.join(skill._skill_dir, "config.yaml") + try: + result = skill.execute( + { + "action": "update_preferences", + "preferences": {"default_chain": "base", "slippage_bps": 75}, + } + ) + assert result["status"] == "updated" + assert result["preferences"]["default_chain"] == "base" + assert result["preferences"]["slippage_bps"] == 75 + with open(config_path, "r", encoding="utf-8") as f: + saved = yaml.safe_load(f) + assert saved["default_chain"] == "base" + finally: + if os.path.exists(config_path): + os.remove(config_path) + skill.user_config = skill._load_user_config() + + +def test_wallet_info_no_secrets(skill): + result = skill.execute({"action": "wallet_info", "intent": {}}) + assert result["status"] == "ready" + assert "address" in result + body = str(result) + assert TEST_KEY not in body + assert TEST_KEY[2:] not in body + + +@patch.object(EvmTxHandlerSkill, "_get_web3") +def test_balances(mock_web3, skill): + w3 = MagicMock() + w3.eth.get_balance.return_value = 10**18 + mock_web3.return_value = w3 + erc20 = MagicMock() + erc20.functions.balanceOf.return_value.call.return_value = 5_000_000 + w3.eth.contract.return_value = erc20 + + result = skill.execute({"action": "balances", "intent": {"chain": "ethereum"}}) + assert result["status"] == "ready" + assert "eth" in result["balances"] From 0ff55fef3155385620d6b8acdb5637f0aed0f941 Mon Sep 17 00:00:00 2001 From: Hendobox <50964581+Hendobox@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:14:06 +0100 Subject: [PATCH 2/6] ci(defi): run evm_tx_handler tests with web3 in CI Install web3 in the CI matrix and pytest the defi/evm_tx_handler bundle. Add an [Unreleased] CHANGELOG entry for the new skill (Part of #142). --- .github/workflows/ci.yml | 4 ++-- CHANGELOG.md | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abc98bf..8a16ff2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: pip install flake8 pytest pytest-mock if [ -f requirements.txt ]; then pip install -r requirements.txt; fi # Attempt to install skill dependencies manually for now (since we don't have a single setup.py yet) - pip install pymupdf anthropic requests + pip install pymupdf anthropic requests "web3>=6.0.0" - name: Lint with flake8 run: | @@ -41,4 +41,4 @@ jobs: ANTHROPIC_API_KEY: "dummy_key_for_ci" ETHERSCAN_API_KEY: "dummy_key_for_ci" run: | - pytest tests/ + pytest tests/ skills/defi/evm_tx_handler/test_skill.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e493dd..7eb7462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ## [Unreleased] +### Added +- **`defi/evm_tx_handler`**: EVM agent wallet skill for structured resolve, Uni V2 quote/preview, transfer, balances, and wallet info on Ethereum and Base (#142). Swap `execute` follows in the same PR series. + ## [0.3.3] - 2026-05-29 ### Added From f5850acf71aaa81132f16ba3202642d31b579bec Mon Sep 17 00:00:00 2001 From: Hendobox <50964581+Hendobox@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:28:31 +0100 Subject: [PATCH 3/6] Revert "ci(defi): run evm_tx_handler tests with web3 in CI" This reverts commit 0ff55fef3155385620d6b8acdb5637f0aed0f941. --- .github/workflows/ci.yml | 4 ++-- CHANGELOG.md | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a16ff2..abc98bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: pip install flake8 pytest pytest-mock if [ -f requirements.txt ]; then pip install -r requirements.txt; fi # Attempt to install skill dependencies manually for now (since we don't have a single setup.py yet) - pip install pymupdf anthropic requests "web3>=6.0.0" + pip install pymupdf anthropic requests - name: Lint with flake8 run: | @@ -41,4 +41,4 @@ jobs: ANTHROPIC_API_KEY: "dummy_key_for_ci" ETHERSCAN_API_KEY: "dummy_key_for_ci" run: | - pytest tests/ skills/defi/evm_tx_handler/test_skill.py + pytest tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb7462..4e493dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,6 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ## [Unreleased] -### Added -- **`defi/evm_tx_handler`**: EVM agent wallet skill for structured resolve, Uni V2 quote/preview, transfer, balances, and wallet info on Ethereum and Base (#142). Swap `execute` follows in the same PR series. - ## [0.3.3] - 2026-05-29 ### Added From 0c9c4753159c505e993560d0db363d445b04cb12 Mon Sep 17 00:00:00 2001 From: Hendobox <50964581+Hendobox@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:23:18 +0100 Subject: [PATCH 4/6] feat(defi): implement evm_tx_handler execute flow (#142) Add Uni V2 swap execution with ERC20 approve handling, max_trade_usd fail-closed enforcement, and optional USD preview output. Reuse quote payload fields for broadcast consistency and expand mocked tests for approve/swap and cap edge cases. Part of #142 --- docs/skills/README.md | 2 +- docs/skills/evm_tx_handler.md | 19 +- skills/defi/evm_tx_handler/abis.py | 85 +++++++ skills/defi/evm_tx_handler/instructions.md | 27 ++- skills/defi/evm_tx_handler/manifest.yaml | 14 +- skills/defi/evm_tx_handler/skill.py | 252 +++++++++++++++++++-- skills/defi/evm_tx_handler/test_skill.py | 147 +++++++++++- 7 files changed, 482 insertions(+), 64 deletions(-) diff --git a/docs/skills/README.md b/docs/skills/README.md index 9554ef9..5db26c2 100644 --- a/docs/skills/README.md +++ b/docs/skills/README.md @@ -23,7 +23,7 @@ On-chain execution and trading for dedicated agent wallets (structured intent, p | Skill | ID | Issuer | Description | | :--- | :--- | :--- | :--- | -| **[EVM Transaction Handler](evm_tx_handler.md)** | `defi/evm_tx_handler` | [@Hendobox](https://github.com/Hendobox) | Quote and preview Uni V2 swaps and send transfers on Ethereum/Base from structured intent (PR1; swap execute in PR2). | +| **[EVM Transaction Handler](evm_tx_handler.md)** | `defi/evm_tx_handler` | [@Hendobox](https://github.com/Hendobox) | Uni V2 quote, preview, execute, and transfer on Ethereum/Base from structured intent (#142). | ## Optimization Middleware skills that operate on text or state to increase performance, security, or efficiency. diff --git a/docs/skills/evm_tx_handler.md b/docs/skills/evm_tx_handler.md index f32534b..6665942 100644 --- a/docs/skills/evm_tx_handler.md +++ b/docs/skills/evm_tx_handler.md @@ -5,19 +5,19 @@ [Skill Library](README.md) · [Testing](../TESTING.md) -Structured EVM operations for a **dedicated agent wallet**: resolve trade intent, quote Uniswap V2 swaps, preview outcomes, and send native/ERC20 transfers. **PR1** ships read/plan/transfer paths; **swap `execute`** arrives in PR2. +Structured EVM operations for a **dedicated agent wallet**: resolve trade intent, quote Uniswap V2 swaps, preview outcomes, execute swaps, and send native/ERC20 transfers on Ethereum and Base. -## Capabilities (PR1) +## Capabilities | Action | Description | |--------|-------------| | `resolve` | Merge intent with config and YAML registries; surface missing fields | -| `quote` / `preview` | On-chain Uni V2 quote (buy/sell) | +| `quote` / `preview` | On-chain Uni V2 quote (buy/sell); optional CoinGecko USD in preview | +| `execute` | Approve (if needed) + Uni V2 swap using the same quote payload | | `transfer` | Sign and send native or ERC20 (with optional confirmation gate) | | `balances` | Wallet balances for registered tokens | | `wallet_info` | Address, supported chains, preferences (no secrets) | | `update_preferences` | Persist allowed keys to `config.yaml` | -| `execute` | Returns `not_available` until PR2 | ## Environment @@ -26,7 +26,7 @@ Structured EVM operations for a **dedicated agent wallet**: resolve trade intent | `AGENT_WALLET_PRIVATE_KEY` | Yes | Dedicated agent wallet (never in tool args) | | `ETHEREUM_RPC_URL` | If using `ethereum` | JSON-RPC | | `BASE_RPC_URL` | If using `base` | JSON-RPC | -| `COINGECKO_API_KEY` | No | Reserved for USD caps in a later release | +| `COINGECKO_API_KEY` | No | USD preview and `max_trade_usd` enforcement | Copy `skills/defi/evm_tx_handler/config.yaml.example` to `config.yaml` in the same folder for long-term defaults. See [API keys for skills](../usage/api_keys.md). @@ -38,7 +38,7 @@ Copy `skills/defi/evm_tx_handler/config.yaml.example` to `config.yaml` in the sa ## Usage Examples -Sample user message: *“Buy 10 DEGEN on Base with USDC”* → `resolve` → `quote` → show preview (execution in PR2). +Sample user message: *“Buy 10 DEGEN on Base with USDC”* → `resolve` → `quote` → preview → `execute` with `confirmed: true`. ### Load and run @@ -62,15 +62,14 @@ result = skill.execute({ Provider loops: [Agent loops](../usage/agent_loops.md), [Gemini](../usage/gemini.md), [Claude](../usage/claude.md). -## Limitations (PR1) +## Limitations -- No swap broadcast (`execute` stubbed). - Uniswap V2 only; Ethereum + Base. - No cross-chain bridges or aggregators. - Create and fund the agent wallet outside the skill; key only in `.env`. ## Security -- Fail closed on missing RPC or registry entries. -- `confirm_before_send` blocks transfers until `confirmed: true`. +- Fail closed on missing RPC, registry entries, or USD price when `max_trade_usd` is set. +- `confirm_before_send` blocks execute/transfer until `confirmed: true`. - Not financial or legal advice; agents can mis-parse NL — always preview. diff --git a/skills/defi/evm_tx_handler/abis.py b/skills/defi/evm_tx_handler/abis.py index 583db94..b0a2824 100644 --- a/skills/defi/evm_tx_handler/abis.py +++ b/skills/defi/evm_tx_handler/abis.py @@ -21,6 +21,69 @@ ], "outputs": [{"name": "amounts", "type": "uint256[]"}], }, + { + "name": "swapExactTokensForTokens", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "amountIn", "type": "uint256"}, + {"name": "amountOutMin", "type": "uint256"}, + {"name": "path", "type": "address[]"}, + {"name": "to", "type": "address"}, + {"name": "deadline", "type": "uint256"}, + ], + "outputs": [{"name": "amounts", "type": "uint256[]"}], + }, + { + "name": "swapTokensForExactTokens", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "amountOut", "type": "uint256"}, + {"name": "amountInMax", "type": "uint256"}, + {"name": "path", "type": "address[]"}, + {"name": "to", "type": "address"}, + {"name": "deadline", "type": "uint256"}, + ], + "outputs": [{"name": "amounts", "type": "uint256[]"}], + }, + { + "name": "swapExactETHForTokens", + "type": "function", + "stateMutability": "payable", + "inputs": [ + {"name": "amountOutMin", "type": "uint256"}, + {"name": "path", "type": "address[]"}, + {"name": "to", "type": "address"}, + {"name": "deadline", "type": "uint256"}, + ], + "outputs": [{"name": "amounts", "type": "uint256[]"}], + }, + { + "name": "swapETHForExactTokens", + "type": "function", + "stateMutability": "payable", + "inputs": [ + {"name": "amountOut", "type": "uint256"}, + {"name": "path", "type": "address[]"}, + {"name": "to", "type": "address"}, + {"name": "deadline", "type": "uint256"}, + ], + "outputs": [{"name": "amounts", "type": "uint256[]"}], + }, + { + "name": "swapExactTokensForETH", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "amountIn", "type": "uint256"}, + {"name": "amountOutMin", "type": "uint256"}, + {"name": "path", "type": "address[]"}, + {"name": "to", "type": "address"}, + {"name": "deadline", "type": "uint256"}, + ], + "outputs": [{"name": "amounts", "type": "uint256[]"}], + }, ] ERC20_ABI = [ @@ -38,6 +101,26 @@ "inputs": [], "outputs": [{"name": "", "type": "uint8"}], }, + { + "name": "allowance", + "type": "function", + "stateMutability": "view", + "inputs": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + ], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "name": "approve", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "outputs": [{"name": "", "type": "bool"}], + }, { "name": "transfer", "type": "function", @@ -49,3 +132,5 @@ "outputs": [{"name": "", "type": "bool"}], }, ] + +MAX_UINT256 = 2**256 - 1 diff --git a/skills/defi/evm_tx_handler/instructions.md b/skills/defi/evm_tx_handler/instructions.md index 9a920c3..0993ba3 100644 --- a/skills/defi/evm_tx_handler/instructions.md +++ b/skills/defi/evm_tx_handler/instructions.md @@ -1,4 +1,4 @@ -# EVM Transaction Handler (PR1) +# EVM Transaction Handler You are equipped with **`evm_tx_handler`**: a deterministic EVM tool for a **dedicated agent wallet** on Ethereum and Base. @@ -8,41 +8,41 @@ You are equipped with **`evm_tx_handler`**: a deterministic EVM tool for a **ded |-------------|--------| | Parse natural language into partial `intent` JSON | Merge intent with `config.yaml` and YAML registries | | Ask for missing fields in plain language | Return `missing_fields` and `suggested_defaults` | -| Show `preview` to the user and obtain approval | Build on-chain quotes; **do not** run swaps in PR1 | -| Pass `confirmed: true` after approval | Sign and broadcast **transfers** only (PR1) | +| Show `preview` to the user and obtain approval | Build on-chain quotes; sign swaps and transfers when confirmed | +| Pass `confirmed: true` after approval | Approve (if needed), swap, or transfer; return tx hash + receipt | **Do not** expect the skill to parse free text. **Do not** pass private keys in tool arguments. -## PR1 capabilities +## Actions | Action | Use when | |--------|----------| | `resolve` | Intent is incomplete — check `missing_fields` | -| `quote` | Buy/sell intent is complete — get amounts and path | +| `quote` | Buy/sell intent is complete — get amounts, path, optional USD | | `preview` | Same as quote but preview-focused response | -| `execute` | **Not available in PR1** — returns `not_available`; swaps ship in PR2 | +| `execute` | User confirmed — broadcast Uni V2 swap (reuses fresh quote payload) | | `transfer` | Send native ETH or ERC20 to `0x…` or addressbook label | | `balances` | List wallet balances on a chain | | `wallet_info` | Agent address, chains, preferences (no secrets) | | `update_preferences` | User explicitly asks to change defaults in `config.yaml` | -## Typical buy flow (PR1 stops before swap) +## Typical buy flow 1. User: “Buy 10 Degen on Base with USDC.” 2. `resolve` with `{ "side": "buy", "chain": "base", "target_asset": "degen", "amount": 10, "amount_kind": "target_out" }`. 3. If `spend_asset` missing, ask user; suggest `suggested_defaults.spend_asset`. -4. `quote` then show `preview` (you pay / you receive, rate, gas, warnings). -5. Tell user swap execution is **not enabled yet**; PR2 will add `execute` with `confirmed: true`. +4. `quote` then show `preview` (you pay / you receive, rate, gas, optional `usd`, warnings). +5. After user approval, `execute` with the same intent and `confirmed: true`. ## Transfer flow -1. `resolve` or build intent: `chain`, `target_asset`, `amount`, `recipient` (label or address). +1. Build intent: `chain`, `target_asset`, `amount`, `recipient` (label or address). 2. `transfer` with `confirmed: false` first if `confirm_before_send` — skill returns `needs_confirmation`. 3. After user approves, `transfer` with `confirmed: true`. ## Intent fields -- `side`: `buy` | `sell` (transfers use action `transfer`, not `side: send` in quotes) +- `side`: `buy` | `sell` (transfers use action `transfer`) - `chain`: `ethereum` | `base` (default from config) - `target_asset` / `spend_asset`: symbols from `data/tokens.yaml` - `amount` + `amount_kind`: `target_out` for “buy 10 DEGEN”; `spend_in` for “sell 100 DEGEN” @@ -51,7 +51,6 @@ You are equipped with **`evm_tx_handler`**: a deterministic EVM tool for a **ded ## Safety -- Always preview transfers (recipient resolved, amount, chain). -- Warn that agents can mis-parse names and amounts. +- Always preview before `execute` or `transfer`. +- `max_trade_usd` in config blocks quotes/executes when USD price is unavailable (fail closed). - Recommend `finance/wallet_screening` for unknown recipient addresses before large sends. -- Respect `max_trade_usd` when enabled (enforced in PR2 quotes/execute). diff --git a/skills/defi/evm_tx_handler/manifest.yaml b/skills/defi/evm_tx_handler/manifest.yaml index ede2ef0..69f4c0c 100644 --- a/skills/defi/evm_tx_handler/manifest.yaml +++ b/skills/defi/evm_tx_handler/manifest.yaml @@ -1,10 +1,9 @@ name: evm_tx_handler -version: 0.1.0 +version: 0.2.0 description: | - Operates a dedicated EVM agent wallet for structured buy/sell quotes, previews, - and transfers on Ethereum and Base. PR1: resolve, quote, preview, transfer, - balances, wallet_info. Swap execution (execute) follows in PR2. -short_description: "EVM agent wallet — quote, preview, and transfer from structured intent." + Operates a dedicated EVM agent wallet for structured buy/sell on Ethereum and Base: + resolve, Uni V2 quote/preview/execute, transfer, balances, and wallet info. +short_description: "EVM agent wallet — quote, swap, and transfer from structured intent." category: defi issuer: name: Hendobox @@ -31,7 +30,7 @@ parameters: Partial or full trade/send intent (side, chain, assets, amount, recipient, gas_policy). confirmed: type: boolean - description: Required true for transfer when confirm_before_send is enabled. + description: Required true for execute/transfer when confirm_before_send is enabled. preferences: type: object description: Long-term preference updates (update_preferences only). @@ -55,8 +54,9 @@ env_vars: description: "JSON-RPC URL for Base (required when using chain base)." required: false COINGECKO_API_KEY: - description: "Optional; reserved for USD estimates in later releases." + description: "Optional CoinGecko API key for USD estimates and max_trade_usd enforcement." required: false requirements: - web3>=6.0.0 - pyyaml + - requests diff --git a/skills/defi/evm_tx_handler/skill.py b/skills/defi/evm_tx_handler/skill.py index f5ff851..64d1bbc 100644 --- a/skills/defi/evm_tx_handler/skill.py +++ b/skills/defi/evm_tx_handler/skill.py @@ -1,6 +1,6 @@ """ -EVM transaction handler — PR1: resolve, quote, preview, transfer, balances, -wallet_info, update_preferences. Swap execute() ships in PR2. +EVM transaction handler — structured intent, Uni V2 quote/preview/execute, +transfer, balances, and wallet info for a dedicated agent wallet. """ from __future__ import annotations @@ -13,6 +13,7 @@ from decimal import Decimal, ROUND_DOWN from typing import Any, Dict, List, Optional, Tuple +import requests import yaml from eth_account import Account from web3 import Web3 @@ -23,7 +24,9 @@ _SKILL_DIR = os.path.dirname(os.path.abspath(__file__)) if _SKILL_DIR not in sys.path: sys.path.insert(0, _SKILL_DIR) -from abis import ERC20_ABI, ROUTER_V2_ABI # noqa: E402 +from abis import ERC20_ABI, MAX_UINT256, ROUTER_V2_ABI # noqa: E402 + +_COINGECKO_PLATFORM = {"ethereum": "ethereum", "base": "base"} _ETH_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") _SENSITIVE_ENV_SUFFIXES = ("PRIVATE_KEY", "SECRET", "MNEMONIC") @@ -393,8 +396,96 @@ def _build_quote(self, resolved: Dict[str, Any]) -> Dict[str, Any]: "gas_estimate": gas, "side": resolved["side"], "chain": chain, + "amount_kind": amount_kind, } + @staticmethod + def _quote_api_payload(quote: Dict[str, Any]) -> Dict[str, Any]: + return { + "path": quote["path"], + "amount_in_wei": quote["amount_in_wei"], + "amount_out_wei": quote["amount_out_wei"], + "min_out_wei": quote["min_out_wei"], + "deadline": quote["deadline"], + } + + def _coingecko_headers(self) -> Dict[str, str]: + headers: Dict[str, str] = {"Accept": "application/json"} + api_key = os.environ.get("COINGECKO_API_KEY") or (self.config or {}).get( + "COINGECKO_API_KEY" + ) + if api_key: + headers["x-cg-pro-api-key"] = str(api_key) + return headers + + def _coingecko_usd_unit_price(self, chain: str, token: Dict[str, Any]) -> Optional[float]: + if chain not in _COINGECKO_PLATFORM: + return None + try: + if token.get("native"): + response = requests.get( + "https://api.coingecko.com/api/v3/simple/price", + params={"ids": "ethereum", "vs_currencies": "usd"}, + headers=self._coingecko_headers(), + timeout=10, + ) + response.raise_for_status() + return float(response.json()["ethereum"]["usd"]) + + platform = _COINGECKO_PLATFORM[chain] + address = token["address"].lower() + response = requests.get( + f"https://api.coingecko.com/api/v3/simple/token_price/{platform}", + params={"contract_addresses": address, "vs_currencies": "usd"}, + headers=self._coingecko_headers(), + timeout=10, + ) + response.raise_for_status() + data = response.json() + for key, value in data.items(): + if key.lower() == address: + return float(value["usd"]) + return None + except (requests.RequestException, KeyError, TypeError, ValueError): + return None + + def _usd_for_token_amount( + self, chain: str, token: Dict[str, Any], amount_wei: int + ) -> Optional[float]: + unit = self._coingecko_usd_unit_price(chain, token) + if unit is None: + return None + human = Decimal(amount_wei) / Decimal(10 ** int(token["decimals"])) + return float(human * Decimal(str(unit))) + + def _enforce_max_trade_usd(self, quote: Dict[str, Any]) -> None: + cap = self.user_config.get("max_trade_usd") + if cap is None: + return + pay_usd = self._usd_for_token_amount( + quote["chain"], quote["token_in"], int(quote["amount_in_wei"]) + ) + if pay_usd is None: + raise ValueError( + "max_trade_usd is configured but USD price for the pay asset is unavailable. " + "Set COINGECKO_API_KEY or retry later." + ) + if pay_usd > float(cap): + raise ValueError( + f"Trade pay amount ${pay_usd:.2f} exceeds max_trade_usd ${float(cap):.2f}." + ) + + def _preview_usd(self, quote: Dict[str, Any]) -> Optional[Dict[str, Any]]: + pay = self._usd_for_token_amount( + quote["chain"], quote["token_in"], int(quote["amount_in_wei"]) + ) + receive = self._usd_for_token_amount( + quote["chain"], quote["token_out"], int(quote["amount_out_wei"]) + ) + if pay is None and receive is None: + return None + return {"pay": pay, "receive": receive} + def _preview_from_quote(self, quote: Dict[str, Any]) -> Dict[str, Any]: tin, tout = quote["token_in"], quote["token_out"] pay_amt = self._from_wei(int(quote["amount_in_wei"]), tin["decimals"]) @@ -406,7 +497,7 @@ def _preview_from_quote(self, quote: Dict[str, Any]) -> Dict[str, Any]: f"{format(Decimal(recv_amt) / Decimal(pay_amt), 'f')} {tout['symbol']}" ) - return { + preview: Dict[str, Any] = { "side": quote["side"], "chain": quote["chain"], "you_pay": {"asset": tin["symbol"], "amount": pay_amt}, @@ -414,11 +505,12 @@ def _preview_from_quote(self, quote: Dict[str, Any]) -> Dict[str, Any]: "rate": rate, "gas_estimate": quote["gas_estimate"], "router": "uniswap_v2", - "warnings": [ - "Rates are indicative; slippage may apply.", - "Swap execution is not enabled in PR1.", - ], + "warnings": ["Rates are indicative; slippage may apply."], } + usd = self._preview_usd(quote) + if usd is not None: + preview["usd"] = usd + return preview def _action_quote(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: resolved = self._merge_intent(intent) @@ -429,18 +521,13 @@ def _action_quote(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict return self._error(f"Cannot quote; missing fields: {', '.join(missing)}.") quote = self._build_quote(resolved) + self._enforce_max_trade_usd(quote) preview = self._preview_from_quote(quote) confirm = bool(self.user_config.get("confirm_before_send", True)) return { "status": "ready", "preview": preview, - "quote": { - "path": quote["path"], - "amount_in_wei": quote["amount_in_wei"], - "amount_out_wei": quote["amount_out_wei"], - "min_out_wei": quote["min_out_wei"], - "deadline": quote["deadline"], - }, + "quote": self._quote_api_payload(quote), "requires_confirmation": confirm, } @@ -454,18 +541,135 @@ def _action_preview(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dic "requires_confirmation": result.get("requires_confirmation", True), } - def _action_execute(self, _intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: + def _base_tx_params( + self, w3: Web3, chain: str, policy: str, from_address: str + ) -> Dict[str, Any]: + max_fee, priority = self._eip1559_fees(w3, policy) + return { + "from": from_address, + "chainId": int(self.chains[chain]["chain_id"]), + "nonce": w3.eth.get_transaction_count(from_address), + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": priority, + } + + def _approve_router_if_needed( + self, + w3: Web3, + chain: str, + token_address: str, + router_address: str, + amount_wei: int, + policy: str, + owner: str, + ) -> Optional[str]: + erc20 = w3.eth.contract(address=token_address, abi=ERC20_ABI) + allowance = erc20.functions.allowance(owner, router_address).call() + if allowance >= amount_wei: + return None + base = self._base_tx_params(w3, chain, policy, owner) + tx = erc20.functions.approve(router_address, MAX_UINT256).build_transaction(base) + return self._sign_and_send(w3, tx) + + def _build_swap_transaction( + self, + w3: Web3, + chain: str, + quote: Dict[str, Any], + to_address: str, + policy: str, + from_address: str, + ) -> Dict[str, Any]: + router = self._router(w3, chain) + token_in = quote["token_in"] + token_out = quote["token_out"] + amount_in = int(quote["amount_in_wei"]) + amount_out = int(quote["amount_out_wei"]) + min_out = int(quote["min_out_wei"]) + path = quote["path"] + deadline = quote["deadline"] + amount_kind = quote.get("amount_kind", "target_out") + base = self._base_tx_params(w3, chain, policy, from_address) + + if token_in.get("native"): + if amount_kind == "target_out": + return router.functions.swapETHForExactTokens( + amount_out, path, to_address, deadline + ).build_transaction({**base, "value": amount_in}) + return router.functions.swapExactETHForTokens( + min_out, path, to_address, deadline + ).build_transaction({**base, "value": amount_in}) + + if token_out.get("native"): + return router.functions.swapExactTokensForETH( + amount_in, min_out, path, to_address, deadline + ).build_transaction(base) + + if amount_kind == "target_out": + return router.functions.swapTokensForExactTokens( + amount_out, amount_in, path, to_address, deadline + ).build_transaction(base) + + return router.functions.swapExactTokensForTokens( + amount_in, min_out, path, to_address, deadline + ).build_transaction(base) + + def _action_execute(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: need_confirm = self._require_confirmed(params) if need_confirm: return need_confirm - return { - "status": "not_available", - "code": "pr2_execute", - "message": ( - "Swap execution (execute) ships in PR2. Use quote/preview to plan trades; " - "use transfer for sends in PR1." - ), + + resolved = self._merge_intent(intent) + if resolved.get("side") not in ("buy", "sell"): + return self._error("execute requires intent.side buy or sell.") + missing = self._missing_for_trade(resolved) + if missing: + return self._error(f"Cannot execute; missing fields: {', '.join(missing)}.") + + quote = self._build_quote(resolved) + self._enforce_max_trade_usd(quote) + + chain = quote["chain"] + w3 = self._get_web3(chain) + account = self._account() + router_address = Web3.to_checksum_address(self.chains[chain]["router_v2"]) + policy = resolved.get("gas_policy", "normal") + amount_in = int(quote["amount_in_wei"]) + + approve_tx_hash: Optional[str] = None + token_in = quote["token_in"] + if not token_in.get("native"): + approve_tx_hash = self._approve_router_if_needed( + w3, + chain, + token_in["address"], + router_address, + amount_in, + policy, + account.address, + ) + if approve_tx_hash: + approve_receipt = self._wait_receipt(w3, approve_tx_hash) + if not approve_receipt.get("success"): + return self._error("Router approve transaction failed.") + + swap_tx = self._build_swap_transaction( + w3, chain, quote, account.address, policy, account.address + ) + tx_hash = self._sign_and_send(w3, swap_tx) + receipt = self._wait_receipt(w3, tx_hash) + + result: Dict[str, Any] = { + "status": "confirmed", + "tx_hash": tx_hash, + "explorer_url": self._explorer_url(chain, tx_hash), + "receipt": receipt, + "quote": self._quote_api_payload(quote), } + if approve_tx_hash: + result["approve_tx_hash"] = approve_tx_hash + result["approve_explorer_url"] = self._explorer_url(chain, approve_tx_hash) + return result # --- Transfer --- @@ -619,7 +823,7 @@ def _action_wallet_info(self, _intent: Dict[str, Any], _params: Dict[str, Any]) "preview": True, "transfer": True, "balances": True, - "execute": False, + "execute": True, }, } diff --git a/skills/defi/evm_tx_handler/test_skill.py b/skills/defi/evm_tx_handler/test_skill.py index 31750c3..dc74616 100644 --- a/skills/defi/evm_tx_handler/test_skill.py +++ b/skills/defi/evm_tx_handler/test_skill.py @@ -1,4 +1,5 @@ import os +import sys from unittest.mock import MagicMock, patch import pytest @@ -6,11 +7,48 @@ from skillware.core.loader import SkillLoader -from .skill import EvmTxHandlerSkill +_SKILL_DIR = os.path.dirname(__file__) +if _SKILL_DIR not in sys.path: + sys.path.insert(0, _SKILL_DIR) +from abis import ROUTER_V2_ABI # noqa: E402 + +from .skill import EvmTxHandlerSkill # noqa: E402 TEST_KEY = "0x" + "11" * 32 +def _mock_w3_for_erc20_swap(*, allowance: int = 0): + w3 = MagicMock() + w3.eth.gas_price = 10**9 + w3.eth.get_transaction_count.return_value = 0 + w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + + router = MagicMock() + router.functions.getAmountsIn.return_value.call.return_value = [ + 100_000_000, + 10_000_000_000_000_000_000, + ] + router.functions.swapTokensForExactTokens.return_value.build_transaction.return_value = { + "to": "0x4752ba5DBC23f44d87826276bf6fd6b1c372aD24", + "gas": 250000, + } + + erc20 = MagicMock() + erc20.functions.allowance.return_value.call.return_value = allowance + erc20.functions.approve.return_value.build_transaction.return_value = { + "to": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "gas": 60000, + } + + def contract_factory(address=None, abi=None): + if abi == ROUTER_V2_ABI: + return router + return erc20 + + w3.eth.contract.side_effect = contract_factory + return w3 + + @pytest.fixture def skill(tmp_path, monkeypatch): monkeypatch.setenv("AGENT_WALLET_PRIVATE_KEY", TEST_KEY) @@ -116,27 +154,119 @@ def test_quote_sell(mock_web3, skill): assert result["preview"]["side"] == "sell" -def test_execute_not_available_pr1(skill): +def test_execute_needs_confirmation_when_enabled(skill): result = skill.execute( { "action": "execute", "intent": {"side": "buy", "chain": "base", "target_asset": "degen", "spend_asset": "usdc"}, + "confirmed": False, + } + ) + assert result["status"] == "needs_confirmation" + + +@patch.object(EvmTxHandlerSkill, "_wait_receipt") +@patch.object(EvmTxHandlerSkill, "_sign_and_send") +@patch.object(EvmTxHandlerSkill, "_get_web3") +def test_execute_buy_with_approve(mock_web3, mock_send, mock_receipt, skill): + mock_web3.return_value = _mock_w3_for_erc20_swap(allowance=0) + mock_send.side_effect = ["0xapprovehash", "0xswaphash"] + mock_receipt.return_value = {"block_number": 1, "gas_used": 250000, "success": True} + + result = skill.execute( + { + "action": "execute", "confirmed": True, + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", + }, } ) - assert result["status"] == "not_available" - assert result["code"] == "pr2_execute" + assert result["status"] == "confirmed" + assert result["tx_hash"] == "0xswaphash" + assert result["approve_tx_hash"] == "0xapprovehash" + assert mock_send.call_count == 2 + assert "quote" in result -def test_execute_needs_confirmation_when_enabled(skill): +@patch.object(EvmTxHandlerSkill, "_wait_receipt") +@patch.object(EvmTxHandlerSkill, "_sign_and_send") +@patch.object(EvmTxHandlerSkill, "_get_web3") +def test_execute_buy_skips_approve_when_allowance_sufficient( + mock_web3, mock_send, mock_receipt, skill +): + mock_web3.return_value = _mock_w3_for_erc20_swap(allowance=10**30) + mock_send.return_value = "0xswaphash" + mock_receipt.return_value = {"block_number": 1, "gas_used": 250000, "success": True} + result = skill.execute( { "action": "execute", - "intent": {"side": "buy", "chain": "base", "target_asset": "degen", "spend_asset": "usdc"}, - "confirmed": False, + "confirmed": True, + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", + }, } ) - assert result["status"] == "needs_confirmation" + assert result["status"] == "confirmed" + assert "approve_tx_hash" not in result + mock_send.assert_called_once() + + +@patch.object(EvmTxHandlerSkill, "_get_web3") +@patch.object(EvmTxHandlerSkill, "_usd_for_token_amount", return_value=1000.0) +def test_max_trade_usd_blocks_quote(mock_usd, mock_web3, skill): + skill.user_config["max_trade_usd"] = 500 + mock_web3.return_value = _mock_w3_for_erc20_swap() + + result = skill.execute( + { + "action": "quote", + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", + }, + } + ) + assert result["status"] == "error" + assert "max_trade_usd" in result["message"] + + +@patch.object(EvmTxHandlerSkill, "_get_web3") +@patch.object(EvmTxHandlerSkill, "_usd_for_token_amount", return_value=None) +def test_max_trade_usd_fail_closed_without_price(mock_usd, mock_web3, skill): + skill.user_config["max_trade_usd"] = 500 + mock_web3.return_value = _mock_w3_for_erc20_swap() + + result = skill.execute( + { + "action": "quote", + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", + }, + } + ) + assert result["status"] == "error" + assert "USD price" in result["message"] def test_missing_rpc_fail_closed(skill, monkeypatch): @@ -278,6 +408,7 @@ def test_update_preferences(skill, tmp_path): def test_wallet_info_no_secrets(skill): result = skill.execute({"action": "wallet_info", "intent": {}}) assert result["status"] == "ready" + assert result["capabilities"]["execute"] is True assert "address" in result body = str(result) assert TEST_KEY not in body From cad61be48e1badd33fdc5df3b68423a3246172fc Mon Sep 17 00:00:00 2001 From: Hendobox <50964581+Hendobox@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:41:29 +0100 Subject: [PATCH 5/6] docs(defi): polish evm_tx_handler for merge (#142) Add Gemini/Claude examples with demo mode, expand catalog and agent instructions (re-quote drift, approve+swap, balance pre-flight), structured missing wallet key guidance, balance checks before broadcast, and [Unreleased] CHANGELOG entry. Update issuer contact email. Fixes #142 --- CHANGELOG.md | 3 + docs/skills/evm_tx_handler.md | 102 ++++++++-- examples/README.md | 2 + examples/claude_evm_tx_handler.py | 106 +++++++++++ examples/evm_tx_handler_common.py | 148 +++++++++++++++ examples/gemini_evm_tx_handler.py | 102 ++++++++++ skills/defi/evm_tx_handler/card.json | 5 +- skills/defi/evm_tx_handler/instructions.md | 33 +++- skills/defi/evm_tx_handler/manifest.yaml | 3 +- skills/defi/evm_tx_handler/skill.py | 211 +++++++++++++++++++-- skills/defi/evm_tx_handler/test_skill.py | 103 +++++++++- 11 files changed, 775 insertions(+), 43 deletions(-) create mode 100644 examples/claude_evm_tx_handler.py create mode 100644 examples/evm_tx_handler_common.py create mode 100644 examples/gemini_evm_tx_handler.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e493dd..fd72ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ## [Unreleased] +### Added +- **`defi/evm_tx_handler`** (#142): Structured EVM agent wallet skill on Ethereum and Base — `resolve`, Uni V2 `quote`/`preview`/`execute` (approve + swap), `transfer`, `balances`, `wallet_info`, YAML registries, optional CoinGecko USD preview, `max_trade_usd` fail-closed cap, balance pre-flight checks, and mocked Web3 tests. Examples: `examples/gemini_evm_tx_handler.py`, `examples/claude_evm_tx_handler.py`. + ## [0.3.3] - 2026-05-29 ### Added diff --git a/docs/skills/evm_tx_handler.md b/docs/skills/evm_tx_handler.md index 6665942..f455d26 100644 --- a/docs/skills/evm_tx_handler.md +++ b/docs/skills/evm_tx_handler.md @@ -1,7 +1,7 @@ # EVM Transaction Handler **ID**: `defi/evm_tx_handler` -**Issuer**: [@Hendobox](https://github.com/Hendobox) +**Issuer**: [@Hendobox](https://github.com/Hendobox) ([@ARPAHLS](https://github.com/ARPAHLS)) [Skill Library](README.md) · [Testing](../TESTING.md) @@ -13,7 +13,7 @@ Structured EVM operations for a **dedicated agent wallet**: resolve trade intent |--------|-------------| | `resolve` | Merge intent with config and YAML registries; surface missing fields | | `quote` / `preview` | On-chain Uni V2 quote (buy/sell); optional CoinGecko USD in preview | -| `execute` | Approve (if needed) + Uni V2 swap using the same quote payload | +| `execute` | Approve (if needed) + Uni V2 swap; **re-quotes on-chain at broadcast** | | `transfer` | Sign and send native or ERC20 (with optional confirmation gate) | | `balances` | Wallet balances for registered tokens | | `wallet_info` | Address, supported chains, preferences (no secrets) | @@ -23,44 +23,106 @@ Structured EVM operations for a **dedicated agent wallet**: resolve trade intent | Variable | Required | Purpose | | :--- | :--- | :--- | -| `AGENT_WALLET_PRIVATE_KEY` | Yes | Dedicated agent wallet (never in tool args) | +| `AGENT_WALLET_PRIVATE_KEY` | Yes (for signing) | **Dedicated agent wallet only** — hex private key in `.env`, never in tool args or YAML | | `ETHEREUM_RPC_URL` | If using `ethereum` | JSON-RPC | | `BASE_RPC_URL` | If using `base` | JSON-RPC | | `COINGECKO_API_KEY` | No | USD preview and `max_trade_usd` enforcement | -Copy `skills/defi/evm_tx_handler/config.yaml.example` to `config.yaml` in the same folder for long-term defaults. See [API keys for skills](../usage/api_keys.md). +### Dedicated agent wallet (required for signing) + +1. Create a **new wallet** used only for this agent (limited funds). +2. Add the private key to `.env` as `AGENT_WALLET_PRIVATE_KEY` (or the name in `private_key_env` inside `config.yaml`). +3. Load `.env` via `skillware.core.env.load_env_file()` before calling the skill. +4. **Never** use a personal, treasury, or exchange hot wallet. **Never** pass the key in chat or tool arguments. + +If the key is missing, the skill returns `status: missing_config` with setup guidance (see `setup` in the JSON and [API keys for skills](../usage/api_keys.md)). + +Copy `skills/defi/evm_tx_handler/config.yaml.example` to `config.yaml` in the same folder for long-term defaults. + +## Agent notes + +- **Preview drift:** `execute` builds a fresh on-chain quote at broadcast; amounts may differ from the last preview. +- **Quote before confirm:** Call `quote` / `preview` immediately before user approval and `execute`. +- **ERC20 two-step:** Swaps may require `approve` then `swap` (see `approve_tx_hash` in the response). +- **Balances:** Insufficient funds return `status: insufficient_balance` with `agent_hint` before any transaction is sent. ## Registry data - `data/chains.yaml` — chain IDs, RPC env keys, explorers, Uni V2 routers - `data/tokens.yaml` — symbol → contract per chain -- `data/addressbook.yaml` — label → address +- `data/addressbook.yaml` — label → address (replace placeholders before mainnet use) ## Usage Examples -Sample user message: *“Buy 10 DEGEN on Base with USDC”* → `resolve` → `quote` → preview → `execute` with `confirmed: true`. +Guides: [Usage index](../usage/README.md) · [Agent loops](../usage/agent_loops.md) · [API keys](../usage/api_keys.md). + +Sample user message: *“Buy 10 DEGEN on Base with USDC”* → `resolve` → `quote` → `preview` → user confirms → `execute` with `confirmed: true`. + +### Runnable examples + +See [examples/README.md](../../examples/README.md). + +| Provider | Script | +| :--- | :--- | +| Gemini | `examples/gemini_evm_tx_handler.py` | +| Claude | `examples/claude_evm_tx_handler.py` | + +**Demo mode (no live keys):** `EVM_TX_HANDLER_EXAMPLE_DEMO=1 python examples/gemini_evm_tx_handler.py` -### Load and run +### Gemini ```python +import os +import google.genai as genai +from google.genai import types +from skillware.core.env import load_env_file from skillware.core.loader import SkillLoader +load_env_file() bundle = SkillLoader.load_skill("defi/evm_tx_handler") skill = bundle["module"].EvmTxHandlerSkill() - -result = skill.execute({ - "action": "resolve", - "intent": { - "side": "buy", - "chain": "base", - "target_asset": "degen", - "amount": 10, - "amount_kind": "target_out", - }, -}) +client = genai.Client() +tool = SkillLoader.to_gemini_tool(bundle) + +intent = { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", +} + +response = client.models.generate_content( + model="gemini-2.5-flash", + contents="Resolve and quote a buy of 10 DEGEN on Base with USDC.", + config=types.GenerateContentConfig( + tools=[tool], + system_instruction=bundle["instructions"], + ), +) +# On function_call (evm_tx_handler): skill.execute({"action": ..., "intent": ...}) +# After preview + user approval: skill.execute({"action": "execute", "intent": intent, "confirmed": True}) ``` -Provider loops: [Agent loops](../usage/agent_loops.md), [Gemini](../usage/gemini.md), [Claude](../usage/claude.md). +### Claude + +```python +import os +import anthropic +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() +bundle = SkillLoader.load_skill("defi/evm_tx_handler") +skill = bundle["module"].EvmTxHandlerSkill() +client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) +tools = [SkillLoader.to_claude_tool(bundle)] + +# On tool_use (evm_tx_handler): skill.execute(tool_use.input) +# execute example: +# skill.execute({"action": "execute", "intent": intent, "confirmed": True}) +``` ## Limitations @@ -70,6 +132,6 @@ Provider loops: [Agent loops](../usage/agent_loops.md), [Gemini](../usage/gemini ## Security -- Fail closed on missing RPC, registry entries, or USD price when `max_trade_usd` is set. +- Fail closed on missing RPC, registry entries, missing wallet key, or USD price when `max_trade_usd` is set. - `confirm_before_send` blocks execute/transfer until `confirmed: true`. - Not financial or legal advice; agents can mis-parse NL — always preview. diff --git a/examples/README.md b/examples/README.md index e08008e..c6311cc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,6 +29,8 @@ with editable install: `pip install -e ".[gemini]"`. | `gemini_pdf_form_filler.py` | `office/pdf_form_filler` | Gemini | `[gemini]`, `[office]` | `GOOGLE_API_KEY`, `ANTHROPIC_API_KEY` | Uses Gemini as the agent while the PDF skill calls Anthropic for form filling. | | `gemini_tos_evaluator.py` | `compliance/tos_evaluator` | Gemini | `[gemini]` | `GOOGLE_API_KEY` | Runs the terms-of-service evaluator with a Gemini function-calling loop. | | `gemini_wallet_check.py` | `finance/wallet_screening` | Gemini | `[gemini]` | `GOOGLE_API_KEY`, `ETHERSCAN_API_KEY` | Screens an Ethereum wallet with Gemini orchestration and Etherscan data. | +| `gemini_evm_tx_handler.py` | `defi/evm_tx_handler` | Gemini | `[gemini]` | `GOOGLE_API_KEY`; for live swaps also `AGENT_WALLET_PRIVATE_KEY`, `BASE_RPC_URL` or `ETHEREUM_RPC_URL`. Set `EVM_TX_HANDLER_EXAMPLE_DEMO=1` for mocked flow without keys. | Resolve → quote → preview → execute buy flow via Gemini tool loop (or demo mode). | +| `claude_evm_tx_handler.py` | `defi/evm_tx_handler` | Claude | `[claude]` | `ANTHROPIC_API_KEY`; for live swaps also `AGENT_WALLET_PRIVATE_KEY`, RPC URLs. Demo: `EVM_TX_HANDLER_EXAMPLE_DEMO=1`. | Claude tool loop for structured DeFi intent and optional execute after confirmation. | | `mica_claude_flow.py` | `compliance/mica_module` | Claude | `[claude]` | `ANTHROPIC_API_KEY` | Runs a MiCA compliance agent loop through Claude. | | `mica_ollama_flow.py` | `compliance/mica_module` | Ollama | No Skillware extra; install `ollama` separately | None | Runs a local Ollama MiCA flow with prompt-mode tool calling. | | `mica_rag_flow.py` | `compliance/mica_module` | Gemini | `[gemini]` | `GOOGLE_API_KEY` | Runs the MiCA RAG flow with Gemini. | diff --git a/examples/claude_evm_tx_handler.py b/examples/claude_evm_tx_handler.py new file mode 100644 index 0000000..954755f --- /dev/null +++ b/examples/claude_evm_tx_handler.py @@ -0,0 +1,106 @@ +""" +Claude agent loop for defi/evm_tx_handler. + +Environment (live mode): + ANTHROPIC_API_KEY + AGENT_WALLET_PRIVATE_KEY + BASE_RPC_URL and/or ETHEREUM_RPC_URL + optional COINGECKO_API_KEY + +Demo mode (mocked Web3, no live keys): + EVM_TX_HANDLER_EXAMPLE_DEMO=1 python examples/claude_evm_tx_handler.py +""" + +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from evm_tx_handler_common import ( # noqa: E402 + SKILL_ID, + demo_mode_enabled, + handle_tool_call, + load_skill, + run_scripted_flow, +) +from skillware.core.env import load_env_file # noqa: E402 +from skillware.core.loader import SkillLoader # noqa: E402 + + +def main() -> None: + load_env_file() + + if demo_mode_enabled(): + print("DEMO MODE: mocked Web3 — no live RPC or signing.\n") + with load_skill() as skill: + run_scripted_flow(skill, execute=True) + return + + import anthropic + + bundle = SkillLoader.load_skill(SKILL_ID) + skill = bundle["module"].EvmTxHandlerSkill() + client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + tools = [SkillLoader.to_claude_tool(bundle)] + tool_name = bundle["manifest"]["name"] + + user_query = ( + "Plan a buy of 10 DEGEN on Base with USDC: resolve, quote, preview, " + "then execute only after explicit user confirmation." + ) + print(f"User: {user_query}\n") + + message = client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=2048, + system=bundle["instructions"], + messages=[{"role": "user", "content": user_query}], + tools=tools, + ) + + messages = [{"role": "user", "content": user_query}] + + while message.stop_reason == "tool_use": + tool_use = next(block for block in message.content if block.type == "tool_use") + print(f"Claude tool call: {tool_use.name} -> {json.dumps(tool_use.input)}") + + if tool_use.name != tool_name: + print(f"Unknown tool: {tool_use.name}") + break + + result = handle_tool_call(skill, tool_use.input) + print(f"Skill result: {json.dumps(result, indent=2)[:2000]}...\n") + + messages = [ + *messages, + {"role": "assistant", "content": message.content}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": json.dumps(result), + } + ], + }, + ] + + message = client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=2048, + system=bundle["instructions"], + tools=tools, + messages=messages, + ) + + print("\nAgent final response:") + for block in message.content: + if hasattr(block, "text"): + print(block.text) + + +if __name__ == "__main__": + main() diff --git a/examples/evm_tx_handler_common.py b/examples/evm_tx_handler_common.py new file mode 100644 index 0000000..cdc99bf --- /dev/null +++ b/examples/evm_tx_handler_common.py @@ -0,0 +1,148 @@ +""" +Shared helpers for evm_tx_handler examples. + +Set EVM_TX_HANDLER_EXAMPLE_DEMO=1 to run the full resolve → quote → preview → execute +flow with mocked Web3 (no live RPC or private key required). +""" + +from __future__ import annotations + +import json +import os +import sys +from contextlib import contextmanager +from typing import Any, Dict, Iterator +from unittest.mock import MagicMock, patch + +from skillware.core.loader import SkillLoader + +SKILL_ID = "defi/evm_tx_handler" + +BUY_INTENT: Dict[str, Any] = { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", +} + + +def demo_mode_enabled() -> bool: + return os.environ.get("EVM_TX_HANDLER_EXAMPLE_DEMO", "").strip() in ( + "1", + "true", + "yes", + ) + + +@contextmanager +def demo_skill() -> Iterator[Any]: + """Yield an EvmTxHandlerSkill with mocked chain access for examples.""" + bundle = SkillLoader.load_skill(SKILL_ID) + skill = bundle["module"].EvmTxHandlerSkill() + test_key = "0x" + "11" * 32 + os.environ.setdefault("AGENT_WALLET_PRIVATE_KEY", test_key) + os.environ.setdefault("BASE_RPC_URL", "http://localhost:8546") + + skill_dir = os.path.join(os.path.dirname(__file__), "..", "skills", "defi", "evm_tx_handler") + if skill_dir not in sys.path: + sys.path.insert(0, os.path.abspath(skill_dir)) + from abis import ROUTER_V2_ABI # type: ignore[import-not-found] + + w3 = MagicMock() + w3.eth.gas_price = 10**9 + w3.eth.get_transaction_count.return_value = 0 + w3.eth.get_balance = MagicMock(return_value=10**20) + w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + + router = MagicMock() + router.functions.getAmountsIn.return_value.call.return_value = [ + 100_000_000, + 10_000_000_000_000_000_000, + ] + router.functions.swapTokensForExactTokens.return_value.build_transaction.return_value = { + "gas": 250000, + } + + erc20 = MagicMock() + + def _allowance(_owner, _spender): + fn = MagicMock() + fn.call.return_value = 0 + return fn + + def _balance_of(_addr): + fn = MagicMock() + fn.call.return_value = 10**18 + return fn + + erc20.functions.allowance.side_effect = _allowance + erc20.functions.balanceOf.side_effect = _balance_of + erc20.functions.approve.return_value.build_transaction.return_value = {"gas": 60000} + + def contract_factory(address=None, abi=None): + if abi == ROUTER_V2_ABI: + return router + return erc20 + + w3.eth.contract.side_effect = contract_factory + + patches = [ + patch.object(skill, "_get_web3", return_value=w3), + patch.object(skill, "_sign_and_send", side_effect=["0xapprove", "0xswap"]), + patch.object( + skill, + "_wait_receipt", + return_value={"block_number": 1, "gas_used": 200000, "success": True}, + ), + patch.object(skill, "_usd_for_token_amount", return_value=10.0), + ] + for item in patches: + item.start() + try: + yield skill + finally: + for item in reversed(patches): + item.stop() + + +def load_skill(): + if demo_mode_enabled(): + return demo_skill() + bundle = SkillLoader.load_skill(SKILL_ID) + return bundle["module"].EvmTxHandlerSkill() + + +def run_scripted_flow(skill: Any, *, execute: bool = False) -> None: + """Deterministic agent-style sequence using structured skill actions.""" + print("=== evm_tx_handler scripted flow ===\n") + + print("1) resolve") + resolved = skill.execute({"action": "resolve", "intent": BUY_INTENT}) + print(json.dumps(resolved, indent=2)) + + print("\n2) quote") + quote = skill.execute({"action": "quote", "intent": BUY_INTENT}) + print(json.dumps(quote, indent=2)) + + print("\n3) preview") + preview = skill.execute({"action": "preview", "intent": BUY_INTENT}) + print(json.dumps(preview, indent=2)) + + if not execute: + print( + "\nSkipping execute (pass execute=True or set EVM_TX_HANDLER_EXAMPLE_DEMO=1)." + ) + return + + print("\n4) execute (confirmed)") + result = skill.execute( + {"action": "execute", "intent": BUY_INTENT, "confirmed": True} + ) + print(json.dumps(result, indent=2)) + + +def handle_tool_call(skill: Any, tool_input: Dict[str, Any]) -> Dict[str, Any]: + """Dispatch a single evm_tx_handler tool call payload.""" + return skill.execute(tool_input) diff --git a/examples/gemini_evm_tx_handler.py b/examples/gemini_evm_tx_handler.py new file mode 100644 index 0000000..8adcf20 --- /dev/null +++ b/examples/gemini_evm_tx_handler.py @@ -0,0 +1,102 @@ +""" +Gemini agent loop for defi/evm_tx_handler. + +Environment (live mode): + GOOGLE_API_KEY + AGENT_WALLET_PRIVATE_KEY + BASE_RPC_URL and/or ETHEREUM_RPC_URL + optional COINGECKO_API_KEY + +Demo mode (mocked Web3, no live keys): + EVM_TX_HANDLER_EXAMPLE_DEMO=1 python examples/gemini_evm_tx_handler.py +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from evm_tx_handler_common import ( # noqa: E402 + SKILL_ID, + demo_mode_enabled, + handle_tool_call, + load_skill, + run_scripted_flow, +) +from skillware.core.env import load_env_file # noqa: E402 +from skillware.core.loader import SkillLoader # noqa: E402 + + +def main() -> None: + load_env_file() + + if demo_mode_enabled(): + print("DEMO MODE: mocked Web3 — no live RPC or signing.\n") + with load_skill() as skill: + run_scripted_flow(skill, execute=True) + return + + import google.genai as genai + from google.genai import types + + bundle = SkillLoader.load_skill(SKILL_ID) + skill = bundle["module"].EvmTxHandlerSkill() + client = genai.Client() + tool = SkillLoader.to_gemini_tool(bundle) + system_instruction = bundle["instructions"] + + user_query = ( + "Plan a buy of 10 DEGEN on Base paying with USDC. " + "Resolve missing fields, quote, show preview, then execute after I confirm." + ) + print(f"User: {user_query}\n") + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=user_query, + config=types.GenerateContentConfig( + tools=[tool], + system_instruction=system_instruction, + ), + ) + + while response.candidates and response.candidates[0].content.parts: + part = response.candidates[0].content.parts[0] + if not part.function_call: + break + + fn_name = part.function_call.name + fn_args = dict(part.function_call.args) + print(f"Agent tool call: {fn_name} -> {json.dumps(fn_args)}") + + if fn_name != bundle["manifest"]["name"]: + print(f"Unknown tool: {fn_name}") + break + + api_result = handle_tool_call(skill, fn_args) + print(f"Skill result: {json.dumps(api_result, indent=2)[:2000]}...\n") + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=[ + "Use this tool result and continue the trade workflow.", + { + "function_response": { + "name": fn_name, + "response": {"result": api_result}, + } + }, + ], + config=types.GenerateContentConfig( + tools=[tool], + system_instruction=system_instruction, + ), + ) + + print("\nAgent final response:") + print(response.text) + + +if __name__ == "__main__": + main() diff --git a/skills/defi/evm_tx_handler/card.json b/skills/defi/evm_tx_handler/card.json index 8bd8692..6ae6323 100644 --- a/skills/defi/evm_tx_handler/card.json +++ b/skills/defi/evm_tx_handler/card.json @@ -3,8 +3,9 @@ "description": "Quote and transfer from a dedicated agent wallet on Ethereum and Base.", "issuer": { "name": "Hendobox", - "email": "50964581+Hendobox@users.noreply.github.com", - "github": "Hendobox" + "email": "henryonyebuchi53@gmail.com", + "github": "Hendobox", + "org": "ARPAHLS" }, "icon": "wallet", "color": "emerald", diff --git a/skills/defi/evm_tx_handler/instructions.md b/skills/defi/evm_tx_handler/instructions.md index 0993ba3..9619a99 100644 --- a/skills/defi/evm_tx_handler/instructions.md +++ b/skills/defi/evm_tx_handler/instructions.md @@ -13,6 +13,23 @@ You are equipped with **`evm_tx_handler`**: a deterministic EVM tool for a **ded **Do not** expect the skill to parse free text. **Do not** pass private keys in tool arguments. +## Quote vs execute (important) + +- **`quote` / `preview`** use live router math at call time. +- **`execute` re-quotes on-chain at broadcast time** using a fresh quote built from the same intent. +- Preview amounts, rates, and USD hints **may drift** between preview and execute. +- **Always call `quote` or `preview` immediately before** asking the user to confirm and before `execute`. + +## ERC20 swaps: approve then swap + +When the spend asset is an ERC20, the router may need allowance: + +1. Optional **`needs_confirmation`** when `confirm_before_send` is true (human-in-the-loop). +2. On **`execute`**, the skill may broadcast an **`approve`** transaction first, wait for confirmation, then broadcast the **swap**. +3. If the response includes `approve_tx_hash`, tell the user this was a **two-step** flow (approve, then swap). + +Native ETH spends skip ERC20 approve but still consume ETH for gas. + ## Actions | Action | Use when | @@ -20,7 +37,7 @@ You are equipped with **`evm_tx_handler`**: a deterministic EVM tool for a **ded | `resolve` | Intent is incomplete — check `missing_fields` | | `quote` | Buy/sell intent is complete — get amounts, path, optional USD | | `preview` | Same as quote but preview-focused response | -| `execute` | User confirmed — broadcast Uni V2 swap (reuses fresh quote payload) | +| `execute` | User confirmed — broadcast Uni V2 swap (fresh on-chain quote) | | `transfer` | Send native ETH or ERC20 to `0x…` or addressbook label | | `balances` | List wallet balances on a chain | | `wallet_info` | Agent address, chains, preferences (no secrets) | @@ -31,15 +48,23 @@ You are equipped with **`evm_tx_handler`**: a deterministic EVM tool for a **ded 1. User: “Buy 10 Degen on Base with USDC.” 2. `resolve` with `{ "side": "buy", "chain": "base", "target_asset": "degen", "amount": 10, "amount_kind": "target_out" }`. 3. If `spend_asset` missing, ask user; suggest `suggested_defaults.spend_asset`. -4. `quote` then show `preview` (you pay / you receive, rate, gas, optional `usd`, warnings). -5. After user approval, `execute` with the same intent and `confirmed: true`. +4. `quote` then show `preview` (you pay / you receive, rate, gas, warnings, optional `usd`). +5. After explicit user approval, `execute` with the same intent and **`confirmed: true`** (run quote/preview again if more than a few seconds passed). ## Transfer flow 1. Build intent: `chain`, `target_asset`, `amount`, `recipient` (label or address). -2. `transfer` with `confirmed: false` first if `confirm_before_send` — skill returns `needs_confirmation`. +2. `transfer` without `confirmed` first if `confirm_before_send` — skill returns `needs_confirmation`. 3. After user approves, `transfer` with `confirmed: true`. +## Pre-flight balances + +Before swap or transfer broadcast, the skill checks wallet balance (and native ETH for gas on ERC20 operations). On failure you get `status: insufficient_balance` with `agent_hint` — surface this clearly and do not retry execute until funded. + +## Wallet key missing + +If you see `status: missing_config`, the dedicated agent wallet key is not in `.env`. Follow `setup` in the JSON (env var name, docs links). Never ask the user to paste a private key into chat or tool args. + ## Intent fields - `side`: `buy` | `sell` (transfers use action `transfer`) diff --git a/skills/defi/evm_tx_handler/manifest.yaml b/skills/defi/evm_tx_handler/manifest.yaml index 69f4c0c..4ea7755 100644 --- a/skills/defi/evm_tx_handler/manifest.yaml +++ b/skills/defi/evm_tx_handler/manifest.yaml @@ -7,8 +7,9 @@ short_description: "EVM agent wallet — quote, swap, and transfer from structur category: defi issuer: name: Hendobox - email: 50964581+Hendobox@users.noreply.github.com + email: henryonyebuchi53@gmail.com github: Hendobox + org: ARPAHLS parameters: type: object properties: diff --git a/skills/defi/evm_tx_handler/skill.py b/skills/defi/evm_tx_handler/skill.py index 64d1bbc..1ae3295 100644 --- a/skills/defi/evm_tx_handler/skill.py +++ b/skills/defi/evm_tx_handler/skill.py @@ -49,6 +49,13 @@ "high": (1.25, 2.5), "aggressive": (1.5, 3.0), } +_GAS_BUFFER_UNITS = 350_000 +_PREVIEW_DRIFT_WARNING = ( + "execute re-quotes on-chain at broadcast time; preview amounts may drift." +) +_QUOTE_BEFORE_EXECUTE_WARNING = ( + "Call quote (or preview) immediately before user confirmation and execute." +) class EvmTxHandlerSkill(BaseSkill): @@ -194,14 +201,44 @@ def _get_web3(self, chain: str) -> Web3: def _private_key_env(self) -> str: return str(self.user_config.get("private_key_env") or "AGENT_WALLET_PRIVATE_KEY") + def _wallet_key_configured(self) -> bool: + env_name = self._private_key_env() + key = os.environ.get(env_name) or (self.config or {}).get(env_name) + return bool(key and str(key).strip()) + + def _missing_wallet_key_response(self) -> Dict[str, Any]: + env_name = self._private_key_env() + return { + "status": "missing_config", + "message": f"Dedicated agent wallet key is not configured ({env_name}).", + "agent_hint": ( + "Create or fund a disposable agent-only wallet, add its private key to " + f"{env_name} in a local .env file, then retry. Never use a personal, " + "treasury, or exchange hot wallet." + ), + "setup": { + "env_var": env_name, + "where": "Project-root .env (loaded via skillware.core.env.load_env_file)", + "never": "Do not pass private keys in tool arguments, YAML, or chat.", + "wallet_policy": ( + "Use a dedicated agent wallet with limited funds for automated trades only." + ), + "docs": "docs/skills/evm_tx_handler.md#environment", + "api_keys_guide": "docs/usage/api_keys.md", + }, + } + + def _require_wallet_key(self) -> Optional[Dict[str, Any]]: + if self._wallet_key_configured(): + return None + return self._missing_wallet_key_response() + def _account(self): + missing = self._require_wallet_key() + if missing: + raise ValueError(missing["message"]) env_name = self._private_key_env() key = os.environ.get(env_name) or (self.config or {}).get(env_name) - if not key: - raise ValueError( - f"Missing dedicated agent wallet key: set {env_name} in .env " - "(never pass private keys in tool arguments)." - ) if key.startswith("0x"): key = key[2:] return Account.from_key(key) @@ -209,6 +246,67 @@ def _account(self): def _wallet_address(self) -> str: return self._account().address + def _estimate_gas_buffer_wei(self, w3: Web3, policy: str) -> int: + max_fee, _priority = self._eip1559_fees(w3, policy) + return max_fee * _GAS_BUFFER_UNITS + + def _token_balance_wei( + self, w3: Web3, chain: str, token: Dict[str, Any], address: str + ) -> int: + if token.get("native"): + return w3.eth.get_balance(address) + contract = w3.eth.contract(address=token["address"], abi=ERC20_ABI) + return contract.functions.balanceOf(address).call() + + def _preflight_spend_balance( + self, + w3: Web3, + chain: str, + token: Dict[str, Any], + amount_wei: int, + address: str, + policy: str, + *, + needs_gas: bool, + ) -> Optional[Dict[str, Any]]: + balance = self._token_balance_wei(w3, chain, token, address) + if balance < amount_wei: + return { + "status": "insufficient_balance", + "message": ( + f"Insufficient {token['symbol']} balance for this operation." + ), + "agent_hint": ( + f"Wallet holds {self._from_wei(balance, token['decimals'])} " + f"{token['symbol']} but needs at least " + f"{self._from_wei(amount_wei, token['decimals'])}." + ), + "balance": { + "asset": token["symbol"], + "available": self._from_wei(balance, token["decimals"]), + "required": self._from_wei(amount_wei, token["decimals"]), + }, + } + + if needs_gas and not token.get("native"): + gas_buffer = self._estimate_gas_buffer_wei(w3, policy) + native_bal = w3.eth.get_balance(address) + if native_bal < gas_buffer: + return { + "status": "insufficient_balance", + "message": "Insufficient native ETH for gas.", + "agent_hint": ( + "ERC20 swaps and transfers also require ETH on the chain for gas. " + "Top up the agent wallet with a small ETH balance and retry." + ), + "balance": { + "asset": "eth", + "available": self._from_wei(native_bal, 18), + "required_gas_buffer_eth": self._from_wei(gas_buffer, 18), + }, + } + return None + # --- Intent merge / resolve --- def _merge_intent(self, intent: Dict[str, Any]) -> Dict[str, Any]: @@ -505,7 +603,11 @@ def _preview_from_quote(self, quote: Dict[str, Any]) -> Dict[str, Any]: "rate": rate, "gas_estimate": quote["gas_estimate"], "router": "uniswap_v2", - "warnings": ["Rates are indicative; slippage may apply."], + "warnings": [ + "Rates are indicative; slippage may apply.", + _PREVIEW_DRIFT_WARNING, + _QUOTE_BEFORE_EXECUTE_WARNING, + ], } usd = self._preview_usd(quote) if usd is not None: @@ -617,8 +719,17 @@ def _build_swap_transaction( def _action_execute(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: need_confirm = self._require_confirmed(params) if need_confirm: + need_confirm["agent_hint"] = ( + "Show the latest quote/preview to the user, obtain explicit approval, " + "then call execute with confirmed: true. ERC20 swaps may require an " + "on-chain approve transaction before the swap when allowance is low." + ) return need_confirm + missing_key = self._require_wallet_key() + if missing_key: + return missing_key + resolved = self._merge_intent(intent) if resolved.get("side") not in ("buy", "sell"): return self._error("execute requires intent.side buy or sell.") @@ -635,9 +746,38 @@ def _action_execute(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dic router_address = Web3.to_checksum_address(self.chains[chain]["router_v2"]) policy = resolved.get("gas_policy", "normal") amount_in = int(quote["amount_in_wei"]) + token_in = quote["token_in"] + + balance_issue = self._preflight_spend_balance( + w3, + chain, + token_in, + amount_in, + account.address, + policy, + needs_gas=True, + ) + if balance_issue: + return balance_issue + if token_in.get("native"): + gas_buffer = self._estimate_gas_buffer_wei(w3, policy) + native_bal = self._token_balance_wei(w3, chain, token_in, account.address) + if native_bal < amount_in + gas_buffer: + return { + "status": "insufficient_balance", + "message": "Insufficient native ETH for swap amount and gas.", + "agent_hint": ( + "The agent wallet needs enough ETH to cover the swap input and " + "estimated gas. Re-quote after topping up ETH." + ), + "balance": { + "asset": "eth", + "available": self._from_wei(native_bal, 18), + "required": self._from_wei(amount_in + gas_buffer, 18), + }, + } approve_tx_hash: Optional[str] = None - token_in = quote["token_in"] if not token_in.get("native"): approve_tx_hash = self._approve_router_if_needed( w3, @@ -669,6 +809,16 @@ def _action_execute(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dic if approve_tx_hash: result["approve_tx_hash"] = approve_tx_hash result["approve_explorer_url"] = self._explorer_url(chain, approve_tx_hash) + result["agent_hint"] = ( + "Two-step ERC20 swap: approve transaction confirmed; swap broadcast " + "follows in this response. When confirm_before_send is enabled, each " + "step should be shown to the user before signing." + ) + else: + result["agent_hint"] = ( + "Swap broadcast uses a fresh on-chain quote at execute time; amounts " + "may differ slightly from the last preview." + ) return result # --- Transfer --- @@ -722,8 +872,16 @@ def _explorer_url(self, chain: str, tx_hash: str) -> str: def _action_transfer(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: need_confirm = self._require_confirmed(params) if need_confirm: + need_confirm["agent_hint"] = ( + "Confirm recipient, asset, amount, and chain with the user before " + "calling transfer with confirmed: true." + ) return need_confirm + missing_key = self._require_wallet_key() + if missing_key: + return missing_key + resolved = self._merge_intent({**intent, "side": "send"}) chain = resolved["chain"] if not resolved.get("target_asset"): @@ -740,6 +898,32 @@ def _action_transfer(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Di policy = resolved.get("gas_policy", "normal") account = self._account() + balance_issue = self._preflight_spend_balance( + w3, + chain, + token, + amount_wei, + account.address, + policy, + needs_gas=not token.get("native"), + ) + if balance_issue: + return balance_issue + if token.get("native"): + gas_buffer = self._estimate_gas_buffer_wei(w3, policy) + native_bal = w3.eth.get_balance(account.address) + if native_bal < amount_wei + gas_buffer: + return { + "status": "insufficient_balance", + "message": "Insufficient native ETH for transfer amount and gas.", + "agent_hint": "Top up the agent wallet with ETH on this chain and retry.", + "balance": { + "asset": "eth", + "available": self._from_wei(native_bal, 18), + "required": self._from_wei(amount_wei + gas_buffer, 18), + }, + } + chain_id = int(self.chains[chain]["chain_id"]) max_fee, priority = self._eip1559_fees(w3, policy) base_tx: Dict[str, Any] = { @@ -774,6 +958,10 @@ def _action_transfer(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Di # --- Read-only --- def _action_balances(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + missing_key = self._require_wallet_key() + if missing_key: + return missing_key + chain = self._chain_key(intent.get("chain")) w3 = self._get_web3(chain) address = self._wallet_address() @@ -795,10 +983,11 @@ def _action_balances(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> D return {"status": "ready", "chain": chain, "address": address, "balances": balances} def _action_wallet_info(self, _intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: - try: - address = self._wallet_address() - except ValueError as exc: - return {"status": "error", "message": str(exc)} + missing_key = self._require_wallet_key() + if missing_key: + return missing_key + + address = self._wallet_address() prefs = { k: self.user_config[k] diff --git a/skills/defi/evm_tx_handler/test_skill.py b/skills/defi/evm_tx_handler/test_skill.py index dc74616..ce0a23e 100644 --- a/skills/defi/evm_tx_handler/test_skill.py +++ b/skills/defi/evm_tx_handler/test_skill.py @@ -17,10 +17,11 @@ TEST_KEY = "0x" + "11" * 32 -def _mock_w3_for_erc20_swap(*, allowance: int = 0): +def _mock_w3_for_erc20_swap(*, allowance: int = 0, token_balance: int = 10**18): w3 = MagicMock() w3.eth.gas_price = 10**9 w3.eth.get_transaction_count.return_value = 0 + w3.eth.get_balance = MagicMock(return_value=10**20) w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val router = MagicMock() @@ -34,7 +35,19 @@ def _mock_w3_for_erc20_swap(*, allowance: int = 0): } erc20 = MagicMock() - erc20.functions.allowance.return_value.call.return_value = allowance + + def _allowance(_owner, _spender): + fn = MagicMock() + fn.call.return_value = allowance + return fn + + def _balance_of(_addr): + fn = MagicMock() + fn.call.return_value = token_balance + return fn + + erc20.functions.allowance.side_effect = _allowance + erc20.functions.balanceOf.side_effect = _balance_of erc20.functions.approve.return_value.build_transaction.return_value = { "to": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "gas": 60000, @@ -165,10 +178,11 @@ def test_execute_needs_confirmation_when_enabled(skill): assert result["status"] == "needs_confirmation" +@patch.object(EvmTxHandlerSkill, "_usd_for_token_amount", return_value=10.0) @patch.object(EvmTxHandlerSkill, "_wait_receipt") @patch.object(EvmTxHandlerSkill, "_sign_and_send") @patch.object(EvmTxHandlerSkill, "_get_web3") -def test_execute_buy_with_approve(mock_web3, mock_send, mock_receipt, skill): +def test_execute_buy_with_approve(mock_web3, mock_send, mock_receipt, _mock_usd, skill): mock_web3.return_value = _mock_w3_for_erc20_swap(allowance=0) mock_send.side_effect = ["0xapprovehash", "0xswaphash"] mock_receipt.return_value = {"block_number": 1, "gas_used": 250000, "success": True} @@ -194,11 +208,12 @@ def test_execute_buy_with_approve(mock_web3, mock_send, mock_receipt, skill): assert "quote" in result +@patch.object(EvmTxHandlerSkill, "_usd_for_token_amount", return_value=10.0) @patch.object(EvmTxHandlerSkill, "_wait_receipt") @patch.object(EvmTxHandlerSkill, "_sign_and_send") @patch.object(EvmTxHandlerSkill, "_get_web3") def test_execute_buy_skips_approve_when_allowance_sufficient( - mock_web3, mock_send, mock_receipt, skill + mock_web3, mock_send, mock_receipt, _mock_usd, skill ): mock_web3.return_value = _mock_w3_for_erc20_swap(allowance=10**30) mock_send.return_value = "0xswaphash" @@ -226,6 +241,7 @@ def test_execute_buy_skips_approve_when_allowance_sufficient( @patch.object(EvmTxHandlerSkill, "_get_web3") @patch.object(EvmTxHandlerSkill, "_usd_for_token_amount", return_value=1000.0) def test_max_trade_usd_blocks_quote(mock_usd, mock_web3, skill): + original_cap = skill.user_config.get("max_trade_usd") skill.user_config["max_trade_usd"] = 500 mock_web3.return_value = _mock_w3_for_erc20_swap() @@ -244,11 +260,16 @@ def test_max_trade_usd_blocks_quote(mock_usd, mock_web3, skill): ) assert result["status"] == "error" assert "max_trade_usd" in result["message"] + if original_cap is None: + skill.user_config.pop("max_trade_usd", None) + else: + skill.user_config["max_trade_usd"] = original_cap @patch.object(EvmTxHandlerSkill, "_get_web3") @patch.object(EvmTxHandlerSkill, "_usd_for_token_amount", return_value=None) def test_max_trade_usd_fail_closed_without_price(mock_usd, mock_web3, skill): + original_cap = skill.user_config.get("max_trade_usd") skill.user_config["max_trade_usd"] = 500 mock_web3.return_value = _mock_w3_for_erc20_swap() @@ -267,6 +288,10 @@ def test_max_trade_usd_fail_closed_without_price(mock_usd, mock_web3, skill): ) assert result["status"] == "error" assert "USD price" in result["message"] + if original_cap is None: + skill.user_config.pop("max_trade_usd", None) + else: + skill.user_config["max_trade_usd"] = original_cap def test_missing_rpc_fail_closed(skill, monkeypatch): @@ -338,11 +363,19 @@ def test_transfer_resolves_addressbook(mock_web3, mock_send, mock_receipt, skill mock_receipt.return_value = {"block_number": 1, "gas_used": 21000, "success": True} contract = MagicMock() + + def _balance_of(_addr): + fn = MagicMock() + fn.call.return_value = 10**18 + return fn + + contract.functions.balanceOf.side_effect = _balance_of contract.functions.transfer.return_value.build_transaction.return_value = { "to": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "gas": 100000, } w3.eth.contract.return_value = contract + w3.eth.get_balance = MagicMock(return_value=10**18) result = skill.execute( { @@ -405,6 +438,64 @@ def test_update_preferences(skill, tmp_path): skill.user_config = skill._load_user_config() +def test_missing_wallet_key_structured(skill, monkeypatch): + monkeypatch.delenv("AGENT_WALLET_PRIVATE_KEY", raising=False) + result = skill.execute({"action": "wallet_info", "intent": {}}) + assert result["status"] == "missing_config" + assert "AGENT_WALLET_PRIVATE_KEY" in result["setup"]["env_var"] + assert "docs/skills/evm_tx_handler.md" in result["setup"]["docs"] + + +@patch.object(EvmTxHandlerSkill, "_get_web3") +def test_transfer_insufficient_balance(mock_web3, skill): + w3 = MagicMock() + w3.eth.gas_price = 10**9 + w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + mock_web3.return_value = w3 + erc20 = MagicMock() + erc20.functions.balanceOf.side_effect = lambda _a: MagicMock( + call=MagicMock(return_value=1) + ) + w3.eth.contract.return_value = erc20 + w3.eth.get_balance = MagicMock(return_value=10**18) + + result = skill.execute( + { + "action": "transfer", + "confirmed": True, + "intent": { + "chain": "base", + "target_asset": "usdc", + "amount": 10, + "recipient": "mom", + }, + } + ) + assert result["status"] == "insufficient_balance" + assert "agent_hint" in result + + +@patch.object(EvmTxHandlerSkill, "_get_web3") +def test_preview_includes_drift_warning(mock_web3, skill): + mock_web3.return_value = _mock_w3_for_erc20_swap() + result = skill.execute( + { + "action": "quote", + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + "amount": 10, + "amount_kind": "target_out", + }, + } + ) + warnings = " ".join(result["preview"]["warnings"]) + assert "re-quotes" in warnings + assert "immediately before" in warnings + + def test_wallet_info_no_secrets(skill): result = skill.execute({"action": "wallet_info", "intent": {}}) assert result["status"] == "ready" @@ -421,7 +512,9 @@ def test_balances(mock_web3, skill): w3.eth.get_balance.return_value = 10**18 mock_web3.return_value = w3 erc20 = MagicMock() - erc20.functions.balanceOf.return_value.call.return_value = 5_000_000 + erc20.functions.balanceOf.side_effect = lambda _a: MagicMock( + call=MagicMock(return_value=5_000_000) + ) w3.eth.contract.return_value = erc20 result = skill.execute({"action": "balances", "intent": {"chain": "ethereum"}}) From 6ecd350d981a20f465b87b51ee349e50a44aa870 Mon Sep 17 00:00:00 2001 From: Hendobox <50964581+Hendobox@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:03:18 +0100 Subject: [PATCH 6/6] style(defi): format evm_tx_handler for black CI (#142) Apply black after upstream merged #155 so CI formatting check passes. --- examples/evm_tx_handler_common.py | 8 +- skills/defi/evm_tx_handler/skill.py | 108 +++++++++++++++++------ skills/defi/evm_tx_handler/test_skill.py | 36 ++++++-- 3 files changed, 117 insertions(+), 35 deletions(-) diff --git a/examples/evm_tx_handler_common.py b/examples/evm_tx_handler_common.py index cdc99bf..c6196e4 100644 --- a/examples/evm_tx_handler_common.py +++ b/examples/evm_tx_handler_common.py @@ -45,7 +45,9 @@ def demo_skill() -> Iterator[Any]: os.environ.setdefault("AGENT_WALLET_PRIVATE_KEY", test_key) os.environ.setdefault("BASE_RPC_URL", "http://localhost:8546") - skill_dir = os.path.join(os.path.dirname(__file__), "..", "skills", "defi", "evm_tx_handler") + skill_dir = os.path.join( + os.path.dirname(__file__), "..", "skills", "defi", "evm_tx_handler" + ) if skill_dir not in sys.path: sys.path.insert(0, os.path.abspath(skill_dir)) from abis import ROUTER_V2_ABI # type: ignore[import-not-found] @@ -54,7 +56,9 @@ def demo_skill() -> Iterator[Any]: w3.eth.gas_price = 10**9 w3.eth.get_transaction_count.return_value = 0 w3.eth.get_balance = MagicMock(return_value=10**20) - w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + w3.to_wei.side_effect = lambda val, unit: ( + int(val * 10**9) if unit == "gwei" else val + ) router = MagicMock() router.functions.getAmountsIn.return_value.call.return_value = [ diff --git a/skills/defi/evm_tx_handler/skill.py b/skills/defi/evm_tx_handler/skill.py index 1ae3295..35d9c5e 100644 --- a/skills/defi/evm_tx_handler/skill.py +++ b/skills/defi/evm_tx_handler/skill.py @@ -144,9 +144,15 @@ def _save_user_config(self, updates: Dict[str, Any]) -> None: self.user_config = merged def _chain_key(self, chain: Optional[str]) -> str: - key = (chain or self.user_config.get("default_chain") or "ethereum").strip().lower() + key = ( + (chain or self.user_config.get("default_chain") or "ethereum") + .strip() + .lower() + ) if key not in self.chains: - raise ValueError(f"Unknown chain {key!r}. Supported: {', '.join(self.chains)}.") + raise ValueError( + f"Unknown chain {key!r}. Supported: {', '.join(self.chains)}." + ) allowed = self.user_config.get("allowed_chains") if allowed and key not in [c.lower() for c in allowed]: raise ValueError(f"Chain {key!r} is not in allowed_chains.") @@ -199,7 +205,9 @@ def _get_web3(self, chain: str) -> Web3: return self._web3_cache[chain] def _private_key_env(self) -> str: - return str(self.user_config.get("private_key_env") or "AGENT_WALLET_PRIVATE_KEY") + return str( + self.user_config.get("private_key_env") or "AGENT_WALLET_PRIVATE_KEY" + ) def _wallet_key_configured(self) -> bool: env_name = self._private_key_env() @@ -334,7 +342,10 @@ def _merge_intent(self, intent: Dict[str, Any]) -> Dict[str, Any]: if intent.get(key) is not None: merged[key] = intent[key] - if intent.get("slippage_bps") is None and self.user_config.get("slippage_bps") is not None: + if ( + intent.get("slippage_bps") is None + and self.user_config.get("slippage_bps") is not None + ): merged.setdefault("slippage_bps", self.user_config["slippage_bps"]) if side in ("buy", "sell"): @@ -360,17 +371,25 @@ def _missing_for_trade(self, resolved: Dict[str, Any]) -> List[str]: missing.append("amount") return missing - def _suggested_defaults(self, resolved: Dict[str, Any], missing: List[str]) -> Dict[str, Any]: + def _suggested_defaults( + self, resolved: Dict[str, Any], missing: List[str] + ) -> Dict[str, Any]: suggestions: Dict[str, Any] = {} if "spend_asset" in missing: side = resolved.get("side") if side == "buy": - suggestions["spend_asset"] = self.user_config.get("default_spend_asset", "usdc") + suggestions["spend_asset"] = self.user_config.get( + "default_spend_asset", "usdc" + ) elif side == "sell": - suggestions["spend_asset"] = self.user_config.get("default_spend_asset", "usdc") + suggestions["spend_asset"] = self.user_config.get( + "default_spend_asset", "usdc" + ) return suggestions - def _action_resolve(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + def _action_resolve( + self, intent: Dict[str, Any], _params: Dict[str, Any] + ) -> Dict[str, Any]: resolved = self._merge_intent(intent) side = resolved.get("side") @@ -394,7 +413,11 @@ def _action_resolve(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Di self._resolve_token_meta(resolved["chain"], resolved["spend_asset"]) return {"status": "ready", "resolved": resolved} - if side == "send" or intent.get("recipient") or _params.get("action") == "transfer": + if ( + side == "send" + or intent.get("recipient") + or _params.get("action") == "transfer" + ): missing = [] if not resolved.get("target_asset"): missing.append("target_asset") @@ -412,7 +435,9 @@ def _action_resolve(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Di } return {"status": "ready", "resolved": resolved} - return self._error("intent.side must be buy, sell, or use transfer action for sends.") + return self._error( + "intent.side must be buy, sell, or use transfer action for sends." + ) # --- Quote / preview --- @@ -447,7 +472,9 @@ def _router(self, w3: Web3, chain: str) -> Contract: router_addr = Web3.to_checksum_address(self.chains[chain]["router_v2"]) return w3.eth.contract(address=router_addr, abi=ROUTER_V2_ABI) - def _trade_assets(self, resolved: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: + def _trade_assets( + self, resolved: Dict[str, Any] + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: chain = resolved["chain"] side = resolved["side"] target = self._resolve_token_meta(chain, resolved["target_asset"]) @@ -464,7 +491,9 @@ def _build_quote(self, resolved: Dict[str, Any]) -> Dict[str, Any]: router = self._router(w3, chain) amount_kind = (resolved.get("amount_kind") or "target_out").lower() amount = float(resolved["amount"]) - slippage_bps = int(resolved.get("slippage_bps") or self.user_config.get("slippage_bps") or 50) + slippage_bps = int( + resolved.get("slippage_bps") or self.user_config.get("slippage_bps") or 50 + ) if amount_kind == "target_out": amount_out_wei = self._to_wei(amount, token_out["decimals"]) @@ -516,7 +545,9 @@ def _coingecko_headers(self) -> Dict[str, str]: headers["x-cg-pro-api-key"] = str(api_key) return headers - def _coingecko_usd_unit_price(self, chain: str, token: Dict[str, Any]) -> Optional[float]: + def _coingecko_usd_unit_price( + self, chain: str, token: Dict[str, Any] + ) -> Optional[float]: if chain not in _COINGECKO_PLATFORM: return None try: @@ -614,7 +645,9 @@ def _preview_from_quote(self, quote: Dict[str, Any]) -> Dict[str, Any]: preview["usd"] = usd return preview - def _action_quote(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + def _action_quote( + self, intent: Dict[str, Any], _params: Dict[str, Any] + ) -> Dict[str, Any]: resolved = self._merge_intent(intent) if resolved.get("side") not in ("buy", "sell"): return self._error("quote requires intent.side buy or sell.") @@ -633,7 +666,9 @@ def _action_quote(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict "requires_confirmation": confirm, } - def _action_preview(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: + def _action_preview( + self, intent: Dict[str, Any], params: Dict[str, Any] + ) -> Dict[str, Any]: result = self._action_quote(intent, params) if result.get("status") != "ready": return result @@ -670,7 +705,9 @@ def _approve_router_if_needed( if allowance >= amount_wei: return None base = self._base_tx_params(w3, chain, policy, owner) - tx = erc20.functions.approve(router_address, MAX_UINT256).build_transaction(base) + tx = erc20.functions.approve(router_address, MAX_UINT256).build_transaction( + base + ) return self._sign_and_send(w3, tx) def _build_swap_transaction( @@ -716,7 +753,9 @@ def _build_swap_transaction( amount_in, min_out, path, to_address, deadline ).build_transaction(base) - def _action_execute(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: + def _action_execute( + self, intent: Dict[str, Any], params: Dict[str, Any] + ) -> Dict[str, Any]: need_confirm = self._require_confirmed(params) if need_confirm: need_confirm["agent_hint"] = ( @@ -824,7 +863,9 @@ def _action_execute(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dic # --- Transfer --- def _require_confirmed(self, params: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if self.user_config.get("confirm_before_send", True) and not params.get("confirmed"): + if self.user_config.get("confirm_before_send", True) and not params.get( + "confirmed" + ): return { "status": "needs_confirmation", "message": "Set confirmed: true after the user approves this transaction.", @@ -854,7 +895,9 @@ def _sign_and_send(self, w3: Web3, tx: Dict[str, Any]) -> str: tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) return tx_hash.hex() - def _wait_receipt(self, w3: Web3, tx_hash: str, timeout: int = 120) -> Dict[str, Any]: + def _wait_receipt( + self, w3: Web3, tx_hash: str, timeout: int = 120 + ) -> Dict[str, Any]: try: receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout) return { @@ -869,7 +912,9 @@ def _explorer_url(self, chain: str, tx_hash: str) -> str: template = self.chains[chain].get("explorer_tx_url", "") return template.format(tx_hash=tx_hash) - def _action_transfer(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]: + def _action_transfer( + self, intent: Dict[str, Any], params: Dict[str, Any] + ) -> Dict[str, Any]: need_confirm = self._require_confirmed(params) if need_confirm: need_confirm["agent_hint"] = ( @@ -943,7 +988,9 @@ def _action_transfer(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Di } else: contract = w3.eth.contract(address=token["address"], abi=ERC20_ABI) - tx = contract.functions.transfer(recipient, amount_wei).build_transaction(base_tx) + tx = contract.functions.transfer(recipient, amount_wei).build_transaction( + base_tx + ) tx_hash = self._sign_and_send(w3, tx) receipt = self._wait_receipt(w3, tx_hash) @@ -957,7 +1004,9 @@ def _action_transfer(self, intent: Dict[str, Any], params: Dict[str, Any]) -> Di # --- Read-only --- - def _action_balances(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + def _action_balances( + self, intent: Dict[str, Any], _params: Dict[str, Any] + ) -> Dict[str, Any]: missing_key = self._require_wallet_key() if missing_key: return missing_key @@ -980,9 +1029,16 @@ def _action_balances(self, intent: Dict[str, Any], _params: Dict[str, Any]) -> D raw = contract.functions.balanceOf(address).call() balances[symbol] = self._from_wei(raw, int(meta["decimals"])) - return {"status": "ready", "chain": chain, "address": address, "balances": balances} + return { + "status": "ready", + "chain": chain, + "address": address, + "balances": balances, + } - def _action_wallet_info(self, _intent: Dict[str, Any], _params: Dict[str, Any]) -> Dict[str, Any]: + def _action_wallet_info( + self, _intent: Dict[str, Any], _params: Dict[str, Any] + ) -> Dict[str, Any]: missing_key = self._require_wallet_key() if missing_key: return missing_key @@ -1025,7 +1081,9 @@ def _action_update_preferences( invalid = [k for k in updates if k not in _PREFERENCE_KEYS] if invalid: - return self._error(f"Cannot update unknown preference keys: {', '.join(invalid)}.") + return self._error( + f"Cannot update unknown preference keys: {', '.join(invalid)}." + ) if "gas_policy" in updates and updates["gas_policy"] not in _GAS_MULTIPLIERS: return self._error("Invalid gas_policy in preferences.") diff --git a/skills/defi/evm_tx_handler/test_skill.py b/skills/defi/evm_tx_handler/test_skill.py index ce0a23e..950826a 100644 --- a/skills/defi/evm_tx_handler/test_skill.py +++ b/skills/defi/evm_tx_handler/test_skill.py @@ -22,7 +22,9 @@ def _mock_w3_for_erc20_swap(*, allowance: int = 0, token_balance: int = 10**18): w3.eth.gas_price = 10**9 w3.eth.get_transaction_count.return_value = 0 w3.eth.get_balance = MagicMock(return_value=10**20) - w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + w3.to_wei.side_effect = lambda val, unit: ( + int(val * 10**9) if unit == "gwei" else val + ) router = MagicMock() router.functions.getAmountsIn.return_value.call.return_value = [ @@ -87,7 +89,9 @@ def test_loader_loads_skill(monkeypatch): assert bundle["manifest"]["name"] == "evm_tx_handler" cls = bundle["module"].EvmTxHandlerSkill instance = cls() - assert instance.execute({"action": "wallet_info", "intent": {}})["status"] == "ready" + assert ( + instance.execute({"action": "wallet_info", "intent": {}})["status"] == "ready" + ) def test_resolve_missing_spend_asset(skill): @@ -112,11 +116,16 @@ def test_resolve_missing_spend_asset(skill): def test_quote_buy(mock_web3, skill): w3 = MagicMock() w3.eth.gas_price = 10**9 - w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + w3.to_wei.side_effect = lambda val, unit: ( + int(val * 10**9) if unit == "gwei" else val + ) mock_web3.return_value = w3 router = MagicMock() - router.functions.getAmountsIn.return_value.call.return_value = [100_000_000, 10_000_000_000_000_000_000] + router.functions.getAmountsIn.return_value.call.return_value = [ + 100_000_000, + 10_000_000_000_000_000_000, + ] w3.eth.contract.return_value = router result = skill.execute( @@ -143,7 +152,9 @@ def test_quote_buy(mock_web3, skill): def test_quote_sell(mock_web3, skill): w3 = MagicMock() w3.eth.gas_price = 10**9 - w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + w3.to_wei.side_effect = lambda val, unit: ( + int(val * 10**9) if unit == "gwei" else val + ) mock_web3.return_value = w3 router = MagicMock() @@ -171,7 +182,12 @@ def test_execute_needs_confirmation_when_enabled(skill): result = skill.execute( { "action": "execute", - "intent": {"side": "buy", "chain": "base", "target_asset": "degen", "spend_asset": "usdc"}, + "intent": { + "side": "buy", + "chain": "base", + "target_asset": "degen", + "spend_asset": "usdc", + }, "confirmed": False, } ) @@ -357,7 +373,9 @@ def test_transfer_resolves_addressbook(mock_web3, mock_send, mock_receipt, skill w3 = MagicMock() w3.eth.gas_price = 10**9 w3.eth.get_transaction_count.return_value = 0 - w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + w3.to_wei.side_effect = lambda val, unit: ( + int(val * 10**9) if unit == "gwei" else val + ) mock_web3.return_value = w3 mock_send.return_value = "0xabc" mock_receipt.return_value = {"block_number": 1, "gas_used": 21000, "success": True} @@ -450,7 +468,9 @@ def test_missing_wallet_key_structured(skill, monkeypatch): def test_transfer_insufficient_balance(mock_web3, skill): w3 = MagicMock() w3.eth.gas_price = 10**9 - w3.to_wei.side_effect = lambda val, unit: int(val * 10**9) if unit == "gwei" else val + w3.to_wei.side_effect = lambda val, unit: ( + int(val * 10**9) if unit == "gwei" else val + ) mock_web3.return_value = w3 erc20 = MagicMock() erc20.functions.balanceOf.side_effect = lambda _a: MagicMock(