From 70dc6acbd0127de9de2a5f8053d4b308a2800ad2 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 27 Aug 2026 18:06:38 +0000 Subject: [PATCH 1/7] feat: add uniswap-trading AI-agent skill (skeleton) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-facing integration surface for uniswap-python: an Anthropic-format SKILL.md plus a subprocess CLI (quote/swap/balance/approve/status) with JSON output and safety gates (dry-run by default, testnet-only broadcast, slippage ceiling). Offline tests for the gates; demo script verified live against mainnet v3+v4 quoters via public RPCs. Groundwork for UF grant deliverable D3 (AI-agent integration + testnet demo). Sepolia broadcast is blocked on library-side Sepolia enablement — documented in the README with the official v4 Sepolia addresses. Co-Authored-By: Bob Git-Session-Id: d14a73a4-480f-4938-9c06-fcbadbfb603c --- skills/uniswap-trading/README.md | 53 +++ skills/uniswap-trading/SKILL.md | 79 ++++ skills/uniswap-trading/demo_sepolia.sh | 43 +++ .../uniswap-trading/scripts/uniswap_agent.py | 358 ++++++++++++++++++ .../tests/test_safety_gates.py | 94 +++++ 5 files changed, 627 insertions(+) create mode 100644 skills/uniswap-trading/README.md create mode 100644 skills/uniswap-trading/SKILL.md create mode 100755 skills/uniswap-trading/demo_sepolia.sh create mode 100755 skills/uniswap-trading/scripts/uniswap_agent.py create mode 100644 skills/uniswap-trading/tests/test_safety_gates.py diff --git a/skills/uniswap-trading/README.md b/skills/uniswap-trading/README.md new file mode 100644 index 0000000..f51c5ae --- /dev/null +++ b/skills/uniswap-trading/README.md @@ -0,0 +1,53 @@ +# uniswap-trading agent skill + +An AI-agent integration for uniswap-python: a [SKILL.md](SKILL.md) following +the [Anthropic skill format](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) +plus a subprocess-friendly CLI (`scripts/uniswap_agent.py`) that any agent +framework can call. + +## Install into an agent + +- **Claude Code**: symlink or copy this directory to `~/.claude/skills/uniswap-trading` + (or `.claude/skills/uniswap-trading` in a project). The skill loads on demand. +- **gptme**: add this directory to a configured lessons/skills path; the + `description` frontmatter drives matching. +- **Anything else** (LangChain, OpenAI tools, MCP): wrap + `scripts/uniswap_agent.py --json ` as a subprocess tool. The CLI is + the integration surface — stdout is one JSON object, exit codes are stable + (0 ok, 1 runtime error, 2 safety refusal). + +## Demo + +`./demo_sepolia.sh` runs read-only mainnet quotes (v3 + v4, verified working +against public RPCs) plus Sepolia status, and — once the library gains Sepolia +support — a real Sepolia swap with `DEMO_BROADCAST=1` and a funded key. + +## Tests + +```bash +python3 -m pytest skills/uniswap-trading/tests/ -q +``` + +No network needed — tests cover the safety gates and argument surface. + +## Status + +Skeleton (grant deliverable D3 groundwork). Verified working: v3 + v4 +mainnet quotes over public RPCs, dry-run swap plans, all safety gates. +TODO before production: + +- [ ] **Sepolia enablement in the library** (blocks the D3 testnet tx demo): + v3 hardcodes the mainnet quoter/router in `uniswap.py`; the v4 maps in + `constants.py` have no `"sepolia"` entries. Official Sepolia v4 + deployments (PoolManager `0xE03A1074c86CFeDd5C142C4F04F1a1536e203543`, + UniversalRouter `0x3A9D48AB9751398BbFa63ad67599Bb04e4BdF98b`, V4Quoter + `0x61b3f2011a92d183c7dbadbda940a7555ccf9227`, StateView + `0xe1dd9c3fa50edb962e442f60dfbc432e24537e4c`, PositionManager + `0x429ba70129df741B2Ca2a85BC3A2a3328e5c09b4`, Permit2 + `0x000000000022D473030F116dDEE9F6B43aC78BA3`) cover 6 of the 8 + contracts `Uniswap4.__init__` requires — `position_descriptor` and + `reserves_lens` still need deployments/addresses. +- [ ] v4 route/multi-hop support in `swap` (currently single-hop PoolKey) +- [ ] Wire tests into repo CI +- [ ] MCP server wrapper (optional distribution surface) +- [ ] Publish demo transcript + testnet tx hash in docs diff --git a/skills/uniswap-trading/SKILL.md b/skills/uniswap-trading/SKILL.md new file mode 100644 index 0000000..fb63946 --- /dev/null +++ b/skills/uniswap-trading/SKILL.md @@ -0,0 +1,79 @@ +--- +name: uniswap-trading +description: Quote prices and execute Uniswap v2/v3/v4 swaps via uniswap-python. Use when asked to check on-chain token prices, get swap quotes, or trade tokens on Uniswap. Testnet-first — mainnet requires explicit opt-in. Do NOT use for CEX trading, fiat, or chains without Uniswap deployments. +--- + +# Uniswap Trading + +Trade on Uniswap through [uniswap-python](https://github.com/uniswap-python/uniswap-python) +using a single CLI: `scripts/uniswap_agent.py`. All commands are safe to +explore — state-changing operations are **dry-run unless `--broadcast`** is +passed, and non-testnet chains are refused unless explicitly allowed. + +## Setup + +Requires `uniswap-python >= 0.8.0` (`pip install uniswap-python`) and: + +```bash +export PROVIDER=https://ethereum-sepolia-rpc.publicnode.com # any JSON-RPC endpoint +export UNISWAP_AGENT_ADDRESS=0xYourWallet # for balances/swaps +export UNISWAP_AGENT_PRIVATE_KEY=0x... # ONLY for --broadcast +``` + +Never echo `UNISWAP_AGENT_PRIVATE_KEY` or write it to disk. Read-only +commands (status, quote) work with just `PROVIDER`. + +## Commands + +```bash +SKILL_DIR=$(dirname "$0") # or the directory containing this SKILL.md + +# Connectivity + wallet overview +python3 "$SKILL_DIR/scripts/uniswap_agent.py" --json status + +# Quote: how much USDC for 0.01 WETH? (amounts in wei/base units) +python3 "$SKILL_DIR/scripts/uniswap_agent.py" --json quote WETH USDC 10000000000000000 + +# Dry-run swap (prints plan: quote, min-out after slippage; sends nothing) +python3 "$SKILL_DIR/scripts/uniswap_agent.py" --json swap WETH USDC 10000000000000000 + +# Real swap on testnet (requires key; approve the router first for ERC-20 input) +python3 "$SKILL_DIR/scripts/uniswap_agent.py" --json approve WETH --broadcast +python3 "$SKILL_DIR/scripts/uniswap_agent.py" --json swap WETH USDC 10000000000000000 --broadcast + +# Uniswap v4 (single-hop; pool identified by fee + tick spacing) +python3 "$SKILL_DIR/scripts/uniswap_agent.py" --json --version 4 \ + quote WETH USDC 10000000000000000 --fee 3000 --tick-spacing 60 +``` + +On Sepolia the symbols `ETH`, `WETH`, `USDC` resolve automatically; on other +chains pass 0x token addresses. + +## Safety rules (enforced by the CLI, exit code 2 on refusal) + +1. Read-only commands (status, quote, balance) work on any chain. Broadcasting + is limited to known testnets (Sepolia, Arbitrum/Base/Optimism Sepolia) + unless `UNISWAP_AGENT_ALLOW_MAINNET=1` is set. Never set that variable + yourself — ask the human operator. +2. Nothing is signed or sent without `--broadcast`. +3. Slippage above 5% is refused (`UNISWAP_AGENT_MAX_SLIPPAGE` to override). + +## Current network coverage (uniswap-python 0.8.0) + +Quotes work today on the ~18 production networks configured in the library +(mainnet, base, arbitrum, optimism, polygon, ...). Sepolia broadcasting is +pending library-side enablement: the v3 client hardcodes mainnet contract +addresses, and the v4 address maps don't include Sepolia yet. Until then, use +mainnet for read-only quoting and expect `status` (but not `quote`/`swap`) to +work on Sepolia. + +## Interpreting output + +With `--json`, results are a single JSON object on stdout. Errors go to +stderr as `{"error": ..., "kind": "safety"|"runtime"}`; exit code 2 means a +safety guard refused (do not retry with workarounds — report to the human), +1 means a runtime error (bad pool, no liquidity, RPC down — often worth one +retry or a different fee tier). + +Amounts are integers in the token's base units (wei for ETH/WETH: 1 ETH = +10^18). Convert before showing humans. diff --git a/skills/uniswap-trading/demo_sepolia.sh b/skills/uniswap-trading/demo_sepolia.sh new file mode 100755 index 0000000..6dad733 --- /dev/null +++ b/skills/uniswap-trading/demo_sepolia.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Demo for the uniswap-trading agent skill. +# +# Part 1 (works today): read-only quoting against mainnet — no key needed. +# Part 2 (Sepolia broadcast): blocked on library-side Sepolia enablement +# (v3 hardcodes mainnet contracts; v4 address maps lack Sepolia). Once +# uniswap-python configures Sepolia, run with: +# UNISWAP_AGENT_ADDRESS=0x... UNISWAP_AGENT_PRIVATE_KEY=0x... DEMO_BROADCAST=1 +set -euo pipefail + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGENT="python3 $SKILL_DIR/scripts/uniswap_agent.py --json" + +MAINNET_RPC="${MAINNET_RPC:-https://ethereum-rpc.publicnode.com}" +SEPOLIA_RPC="${SEPOLIA_RPC:-https://ethereum-sepolia-rpc.publicnode.com}" + +# 0.001 WETH in wei +QTY=1000000000000000 + +echo "== [mainnet, read-only] status ==" +PROVIDER=$MAINNET_RPC $AGENT status + +echo "== [mainnet, read-only] v3 quote: 0.001 WETH -> USDC ==" +PROVIDER=$MAINNET_RPC $AGENT quote WETH USDC "$QTY" + +echo "== [mainnet, read-only] v4 quote: 0.001 ETH -> USDC ==" +PROVIDER=$MAINNET_RPC $AGENT --version 4 quote ETH USDC "$QTY" --fee 500 --tick-spacing 10 + +echo "== [mainnet] dry-run swap plan (nothing is sent) ==" +PROVIDER=$MAINNET_RPC $AGENT swap WETH USDC "$QTY" + +echo "== [sepolia] status ==" +PROVIDER=$SEPOLIA_RPC $AGENT status + +if [ "${DEMO_BROADCAST:-0}" = "1" ]; then + echo "== [sepolia] approve WETH (broadcast) ==" + PROVIDER=$SEPOLIA_RPC $AGENT approve WETH --broadcast + echo "== [sepolia] swap 0.001 WETH -> USDC (broadcast) ==" + PROVIDER=$SEPOLIA_RPC $AGENT swap WETH USDC "$QTY" --broadcast +else + echo "(Sepolia swap skipped: needs DEMO_BROADCAST=1, a funded key, and" + echo " library-side Sepolia contract enablement — see README status list)" +fi diff --git a/skills/uniswap-trading/scripts/uniswap_agent.py b/skills/uniswap-trading/scripts/uniswap_agent.py new file mode 100755 index 0000000..278985e --- /dev/null +++ b/skills/uniswap-trading/scripts/uniswap_agent.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Agent-facing CLI wrapper around uniswap-python. + +Designed to be invoked by AI agents (Claude Code skills, gptme tools, MCP +servers) as a plain subprocess. Every command supports ``--json`` for +machine-readable output. + +Safety model (testnet-first): +- Read-only commands (status, quote, balance) work on any chain. +- Broadcasting is only allowed on well-known testnets unless + ``UNISWAP_AGENT_ALLOW_MAINNET=1`` is set. +- All state-changing commands are dry-run by default; pass ``--broadcast`` + to actually sign and send. A private key is only required to broadcast. +- Slippage is capped at 5% unless ``UNISWAP_AGENT_MAX_SLIPPAGE`` raises it. + +Environment: + PROVIDER JSON-RPC endpoint (uniswap-python convention) + UNISWAP_AGENT_ADDRESS Wallet address (read-only ops work without it) + UNISWAP_AGENT_PRIVATE_KEY Private key; only needed with --broadcast + UNISWAP_AGENT_ALLOW_MAINNET Set to 1 to allow non-testnet chains + UNISWAP_AGENT_MAX_SLIPPAGE Override the 0.05 slippage ceiling (float) +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass +from typing import Any, Optional + +ETH = "0x0000000000000000000000000000000000000000" + +# Chains the wrapper will BROADCAST on without explicit mainnet opt-in. +# Read-only operations are allowed on any chain. +TESTNET_CHAIN_IDS = { + 11155111: "sepolia", + 421614: "arbitrum-sepolia", + 84532: "base-sepolia", + 11155420: "optimism-sepolia", +} + +# Convenience symbols per chain, so agents can say WETH instead of an address. +TOKEN_ALIASES: dict[int, dict[str, str]] = { + 11155111: { + "ETH": ETH, + "WETH": "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", + "USDC": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + }, + 1: { + "ETH": ETH, + "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + }, +} + +DEFAULT_MAX_SLIPPAGE = 0.05 + + +class SafetyError(Exception): + """A guard refused the operation. Message is agent-readable.""" + + +def check_broadcast_chain_allowed(chain_id: int, allow_mainnet: bool) -> None: + """Gate for state-changing (broadcast) operations only.""" + if chain_id in TESTNET_CHAIN_IDS: + return + if not allow_mainnet: + raise SafetyError( + f"refusing to broadcast on chain id {chain_id} (not a known testnet). " + "Set UNISWAP_AGENT_ALLOW_MAINNET=1 to trade on it deliberately." + ) + + +def check_slippage(slippage: float, ceiling: float) -> None: + if not 0 < slippage <= ceiling: + raise SafetyError( + f"slippage {slippage} outside (0, {ceiling}]; " + "raise UNISWAP_AGENT_MAX_SLIPPAGE only if you understand the risk." + ) + + +def check_broadcast_allowed(broadcast: bool, private_key: Optional[str]) -> None: + if broadcast and not private_key: + raise SafetyError( + "--broadcast requires UNISWAP_AGENT_PRIVATE_KEY; " + "without it only dry-run is available." + ) + + +def resolve_token(symbol_or_addr: str, chain_id: int) -> str: + if symbol_or_addr.startswith("0x"): + return symbol_or_addr + alias = TOKEN_ALIASES.get(chain_id, {}).get(symbol_or_addr.upper()) + if alias is None: + raise SafetyError( + f"unknown token symbol {symbol_or_addr!r} on chain {chain_id}; " + "pass a 0x address instead." + ) + return alias + + +@dataclass +class Config: + provider: str + address: Optional[str] + private_key: Optional[str] + allow_mainnet: bool + max_slippage: float + + @classmethod + def from_env(cls) -> "Config": + provider = os.environ.get("PROVIDER") or os.environ.get("UNISWAP_AGENT_RPC") + if not provider: + raise SafetyError("PROVIDER (JSON-RPC endpoint) is not set.") + return cls( + provider=provider, + address=os.environ.get("UNISWAP_AGENT_ADDRESS"), + private_key=os.environ.get("UNISWAP_AGENT_PRIVATE_KEY"), + allow_mainnet=os.environ.get("UNISWAP_AGENT_ALLOW_MAINNET") == "1", + max_slippage=float( + os.environ.get("UNISWAP_AGENT_MAX_SLIPPAGE", DEFAULT_MAX_SLIPPAGE) + ), + ) + + +def _connect(cfg: Config, version: int) -> Any: + """Instantiate the right uniswap-python client for the chain/version.""" + os.environ["PROVIDER"] = cfg.provider + if version == 4: + from uniswap import Uniswap4 + + return Uniswap4( + address=cfg.address or ETH, + private_key=cfg.private_key, + provider=cfg.provider, + ) + from uniswap import Uniswap + + return Uniswap( + address=cfg.address, + private_key=cfg.private_key, + provider=cfg.provider, + version=version, + ) + + +def _chain_id(client: Any) -> int: + return int(client.w3.eth.chain_id) + + +def cmd_status(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: + client = _connect(cfg, args.version) + chain_id = _chain_id(client) + out: dict[str, Any] = { + "chain_id": chain_id, + "network": TESTNET_CHAIN_IDS.get(chain_id, "non-testnet"), + "version": args.version, + "address": cfg.address, + "can_broadcast": bool(cfg.private_key), + } + if cfg.address: + out["eth_balance_wei"] = int(client.w3.eth.get_balance(cfg.address)) + return out + + +def cmd_quote(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: + client = _connect(cfg, args.version) + chain_id = _chain_id(client) + token_in = resolve_token(args.token_in, chain_id) + token_out = resolve_token(args.token_out, chain_id) + if args.version == 4: + amount_out = client.get_price_input( + token_in, + token_out, + args.qty, + fee=args.fee, + tick_spacing=args.tick_spacing, + ) + else: + amount_out = client.get_price_input(token_in, token_out, args.qty, fee=args.fee) + return { + "chain_id": chain_id, + "version": args.version, + "token_in": token_in, + "token_out": token_out, + "qty_in": args.qty, + "amount_out": int(amount_out), + } + + +def cmd_swap(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: + check_broadcast_allowed(args.broadcast, cfg.private_key) + check_slippage(args.slippage, cfg.max_slippage) + client = _connect(cfg, args.version) + chain_id = _chain_id(client) + if args.broadcast: + check_broadcast_chain_allowed(chain_id, cfg.allow_mainnet) + token_in = resolve_token(args.token_in, chain_id) + token_out = resolve_token(args.token_out, chain_id) + + if args.version == 4: + quoted = int( + client.get_price_input( + token_in, + token_out, + args.qty, + fee=args.fee, + tick_spacing=args.tick_spacing, + ) + ) + else: + quoted = int( + client.get_price_input(token_in, token_out, args.qty, fee=args.fee) + ) + min_out = int(quoted * (1 - args.slippage)) + + plan = { + "chain_id": chain_id, + "version": args.version, + "token_in": token_in, + "token_out": token_out, + "qty_in": args.qty, + "quoted_out": quoted, + "min_out": min_out, + "slippage": args.slippage, + "broadcast": args.broadcast, + } + if not args.broadcast: + plan["note"] = "dry-run: pass --broadcast to sign and send" + return plan + + if args.version == 4: + # Single-hop v4 swap; fee + tick_spacing identify the pool. + from uniswap.types import PoolKey + + c0, c1 = sorted([token_in, token_out], key=str.lower) + pool_key = PoolKey( + currency0=c0, + currency1=c1, + fee=args.fee, + tick_spacing=args.tick_spacing, + hooks=ETH, + ) + tx_hash = client.make_swap_input( + token_in, token_out, args.qty, min_out, swap_pool_key=pool_key + ) + else: + tx_hash = client.make_trade( + token_in, token_out, args.qty, fee=args.fee, slippage=args.slippage + ) + plan["tx_hash"] = tx_hash.hex() if hasattr(tx_hash, "hex") else str(tx_hash) + return plan + + +def cmd_balance(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: + client = _connect(cfg, args.version if args.version != 4 else 3) + chain_id = _chain_id(client) + token = resolve_token(args.token, chain_id) + if token == ETH: + balance = int(client.w3.eth.get_balance(cfg.address)) + else: + balance = int(client.get_token_balance(token)) + return {"chain_id": chain_id, "token": token, "balance": balance} + + +def cmd_approve(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: + check_broadcast_allowed(args.broadcast, cfg.private_key) + client = _connect(cfg, args.version) + chain_id = _chain_id(client) + if args.broadcast: + check_broadcast_chain_allowed(chain_id, cfg.allow_mainnet) + token = resolve_token(args.token, chain_id) + if not args.broadcast: + return { + "token": token, + "note": "dry-run: pass --broadcast to send the approval tx", + } + client.approve(token) + return {"token": token, "approved": True} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="uniswap-agent", description=__doc__.splitlines()[0] + ) + parser.add_argument("--json", action="store_true", help="machine-readable output") + parser.add_argument( + "--version", + type=int, + default=3, + choices=[2, 3, 4], + help="Uniswap protocol version (default: 3)", + ) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("status", help="chain, wallet, and connectivity info") + + p_quote = sub.add_parser("quote", help="quote amount out for an exact input") + p_quote.add_argument("token_in") + p_quote.add_argument("token_out") + p_quote.add_argument("qty", type=int, help="input amount in wei/base units") + p_quote.add_argument("--fee", type=int, default=3000) + p_quote.add_argument("--tick-spacing", type=int, default=60, help="v4 only") + + p_swap = sub.add_parser("swap", help="swap exact input (dry-run by default)") + p_swap.add_argument("token_in") + p_swap.add_argument("token_out") + p_swap.add_argument("qty", type=int, help="input amount in wei/base units") + p_swap.add_argument("--fee", type=int, default=3000) + p_swap.add_argument("--tick-spacing", type=int, default=60, help="v4 only") + p_swap.add_argument("--slippage", type=float, default=0.01) + p_swap.add_argument("--broadcast", action="store_true", help="sign and send") + + p_bal = sub.add_parser("balance", help="wallet balance of a token") + p_bal.add_argument("token") + + p_appr = sub.add_parser("approve", help="approve the router for a token") + p_appr.add_argument("token") + p_appr.add_argument("--broadcast", action="store_true", help="sign and send") + + return parser + + +COMMANDS = { + "status": cmd_status, + "quote": cmd_quote, + "swap": cmd_swap, + "balance": cmd_balance, + "approve": cmd_approve, +} + + +def main(argv: Optional[list[str]] = None) -> int: + args = build_parser().parse_args(argv) + try: + cfg = Config.from_env() + result = COMMANDS[args.command](cfg, args) + except SafetyError as e: + payload = {"error": str(e), "kind": "safety"} + print(json.dumps(payload) if args.json else f"refused: {e}", file=sys.stderr) + return 2 + except Exception as e: # noqa: BLE001 — agents need the message, not a trace + payload = {"error": f"{type(e).__name__}: {e}", "kind": "runtime"} + print(json.dumps(payload) if args.json else f"error: {e}", file=sys.stderr) + return 1 + if args.json: + print(json.dumps(result, indent=2)) + else: + for key, value in result.items(): + print(f"{key}: {value}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/uniswap-trading/tests/test_safety_gates.py b/skills/uniswap-trading/tests/test_safety_gates.py new file mode 100644 index 0000000..133871e --- /dev/null +++ b/skills/uniswap-trading/tests/test_safety_gates.py @@ -0,0 +1,94 @@ +"""Offline tests for the agent CLI's safety gates and argument surface.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).parent.parent / "scripts" / "uniswap_agent.py" +_spec = importlib.util.spec_from_file_location("uniswap_agent", _SCRIPT) +agent = importlib.util.module_from_spec(_spec) +sys.modules["uniswap_agent"] = agent +_spec.loader.exec_module(agent) + + +class TestBroadcastChainGate: + def test_sepolia_allowed(self): + agent.check_broadcast_chain_allowed(11155111, allow_mainnet=False) + + def test_mainnet_refused_by_default(self): + with pytest.raises(agent.SafetyError, match="not a known testnet"): + agent.check_broadcast_chain_allowed(1, allow_mainnet=False) + + def test_mainnet_allowed_with_optin(self): + agent.check_broadcast_chain_allowed(1, allow_mainnet=True) + + def test_unknown_chain_refused(self): + with pytest.raises(agent.SafetyError): + agent.check_broadcast_chain_allowed(56, allow_mainnet=False) + + +class TestSlippageGate: + def test_default_ok(self): + agent.check_slippage(0.01, agent.DEFAULT_MAX_SLIPPAGE) + + def test_above_ceiling_refused(self): + with pytest.raises(agent.SafetyError, match="slippage"): + agent.check_slippage(0.10, agent.DEFAULT_MAX_SLIPPAGE) + + def test_zero_refused(self): + with pytest.raises(agent.SafetyError): + agent.check_slippage(0.0, agent.DEFAULT_MAX_SLIPPAGE) + + def test_raised_ceiling(self): + agent.check_slippage(0.10, 0.15) + + +class TestBroadcastGate: + def test_dry_run_needs_no_key(self): + agent.check_broadcast_allowed(False, None) + + def test_broadcast_without_key_refused(self): + with pytest.raises(agent.SafetyError, match="broadcast"): + agent.check_broadcast_allowed(True, None) + + def test_broadcast_with_key_ok(self): + agent.check_broadcast_allowed(True, "0xdeadbeef") + + +class TestTokenResolution: + def test_address_passthrough(self): + addr = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + assert agent.resolve_token(addr, 11155111) == addr + + def test_sepolia_symbols(self): + assert agent.resolve_token("weth", 11155111).startswith("0x") + assert agent.resolve_token("USDC", 11155111).startswith("0x") + assert agent.resolve_token("ETH", 11155111) == agent.ETH + + def test_unknown_symbol_refused(self): + with pytest.raises(agent.SafetyError, match="unknown token symbol"): + agent.resolve_token("DOGE", 11155111) + + +class TestCLI: + def test_parser_covers_all_commands(self): + parser = agent.build_parser() + for command in agent.COMMANDS: + args = parser.parse_args( + [command] + (["ETH"] if command in ("balance", "approve") else []) + if command != "quote" and command != "swap" + else [command, "WETH", "USDC", "1000"] + ) + assert args.command == command + + def test_missing_provider_is_safety_error(self, monkeypatch): + monkeypatch.delenv("PROVIDER", raising=False) + monkeypatch.delenv("UNISWAP_AGENT_RPC", raising=False) + assert agent.main(["--json", "status"]) == 2 + + def test_broadcast_without_key_exits_2(self, monkeypatch): + monkeypatch.setenv("PROVIDER", "http://localhost:1") + monkeypatch.delenv("UNISWAP_AGENT_PRIVATE_KEY", raising=False) + assert agent.main(["swap", "WETH", "USDC", "1000", "--broadcast"]) == 2 From 09bf93e52a864bbdffde98f1d40d6434e4ff1d82 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 27 Aug 2026 18:14:31 +0000 Subject: [PATCH 2/7] fix(skill): require wallet address for balance Git-Session-Id: d14a73a4-480f-4938-9c06-fcbadbfb603c --- skills/uniswap-trading/scripts/uniswap_agent.py | 4 ++++ skills/uniswap-trading/tests/test_safety_gates.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/skills/uniswap-trading/scripts/uniswap_agent.py b/skills/uniswap-trading/scripts/uniswap_agent.py index 278985e..5c0984b 100755 --- a/skills/uniswap-trading/scripts/uniswap_agent.py +++ b/skills/uniswap-trading/scripts/uniswap_agent.py @@ -256,6 +256,10 @@ def cmd_swap(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: def cmd_balance(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: + if not cfg.address: + raise SafetyError( + "balance requires UNISWAP_AGENT_ADDRESS to identify the wallet" + ) client = _connect(cfg, args.version if args.version != 4 else 3) chain_id = _chain_id(client) token = resolve_token(args.token, chain_id) diff --git a/skills/uniswap-trading/tests/test_safety_gates.py b/skills/uniswap-trading/tests/test_safety_gates.py index 133871e..cd3ba56 100644 --- a/skills/uniswap-trading/tests/test_safety_gates.py +++ b/skills/uniswap-trading/tests/test_safety_gates.py @@ -73,6 +73,11 @@ def test_unknown_symbol_refused(self): class TestCLI: + def test_balance_without_address_exits_2_before_connecting(self, monkeypatch): + monkeypatch.setenv("PROVIDER", "http://localhost:1") + monkeypatch.delenv("UNISWAP_AGENT_ADDRESS", raising=False) + assert agent.main(["balance", "ETH"]) == 2 + def test_parser_covers_all_commands(self): parser = agent.build_parser() for command in agent.COMMANDS: From 4efdb864bf812bc170e26ee11e412576dc504164 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 27 Aug 2026 18:15:33 +0000 Subject: [PATCH 3/7] docs(skill): clarify balance address requirement Git-Session-Id: d14a73a4-480f-4938-9c06-fcbadbfb603c --- skills/uniswap-trading/scripts/uniswap_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/uniswap-trading/scripts/uniswap_agent.py b/skills/uniswap-trading/scripts/uniswap_agent.py index 5c0984b..d646020 100755 --- a/skills/uniswap-trading/scripts/uniswap_agent.py +++ b/skills/uniswap-trading/scripts/uniswap_agent.py @@ -15,7 +15,7 @@ Environment: PROVIDER JSON-RPC endpoint (uniswap-python convention) - UNISWAP_AGENT_ADDRESS Wallet address (read-only ops work without it) + UNISWAP_AGENT_ADDRESS Wallet address; required by balance UNISWAP_AGENT_PRIVATE_KEY Private key; only needed with --broadcast UNISWAP_AGENT_ALLOW_MAINNET Set to 1 to allow non-testnet chains UNISWAP_AGENT_MAX_SLIPPAGE Override the 0.05 slippage ceiling (float) From 29a5c7ab7ca98f7012c4125b9ffc18a987dfee6b Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 27 Aug 2026 19:09:07 +0000 Subject: [PATCH 4/7] fix(skill): validate balance wallet address Git-Session-Id: d14a73a4-480f-4938-9c06-fcbadbfb603c --- .../uniswap-trading/scripts/uniswap_agent.py | 26 +++++++++++++++---- .../tests/test_safety_gates.py | 17 ++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/skills/uniswap-trading/scripts/uniswap_agent.py b/skills/uniswap-trading/scripts/uniswap_agent.py index d646020..60a4b1a 100755 --- a/skills/uniswap-trading/scripts/uniswap_agent.py +++ b/skills/uniswap-trading/scripts/uniswap_agent.py @@ -90,6 +90,24 @@ def check_broadcast_allowed(broadcast: bool, private_key: Optional[str]) -> None ) +def require_wallet_address(address: Optional[str]) -> str: + if not address: + raise SafetyError( + "balance requires UNISWAP_AGENT_ADDRESS to identify the wallet" + ) + if len(address) != 42 or not address.startswith("0x"): + raise SafetyError("UNISWAP_AGENT_ADDRESS must be a non-zero EVM address") + try: + value = int(address[2:], 16) + except ValueError as error: + raise SafetyError( + "UNISWAP_AGENT_ADDRESS must be a non-zero EVM address" + ) from error + if value == 0: + raise SafetyError("UNISWAP_AGENT_ADDRESS must be a non-zero EVM address") + return address + + def resolve_token(symbol_or_addr: str, chain_id: int) -> str: if symbol_or_addr.startswith("0x"): return symbol_or_addr @@ -256,15 +274,13 @@ def cmd_swap(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: def cmd_balance(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: - if not cfg.address: - raise SafetyError( - "balance requires UNISWAP_AGENT_ADDRESS to identify the wallet" - ) + address = require_wallet_address(cfg.address) + cfg.address = address client = _connect(cfg, args.version if args.version != 4 else 3) chain_id = _chain_id(client) token = resolve_token(args.token, chain_id) if token == ETH: - balance = int(client.w3.eth.get_balance(cfg.address)) + balance = int(client.w3.eth.get_balance(address)) else: balance = int(client.get_token_balance(token)) return {"chain_id": chain_id, "token": token, "balance": balance} diff --git a/skills/uniswap-trading/tests/test_safety_gates.py b/skills/uniswap-trading/tests/test_safety_gates.py index cd3ba56..7d4ec28 100644 --- a/skills/uniswap-trading/tests/test_safety_gates.py +++ b/skills/uniswap-trading/tests/test_safety_gates.py @@ -73,9 +73,22 @@ def test_unknown_symbol_refused(self): class TestCLI: - def test_balance_without_address_exits_2_before_connecting(self, monkeypatch): + @pytest.mark.parametrize( + "address", + [ + None, + "not-an-address", + "0x0000000000000000000000000000000000000000", + ], + ) + def test_balance_rejects_invalid_address_before_connecting( + self, monkeypatch, address + ): monkeypatch.setenv("PROVIDER", "http://localhost:1") - monkeypatch.delenv("UNISWAP_AGENT_ADDRESS", raising=False) + if address is None: + monkeypatch.delenv("UNISWAP_AGENT_ADDRESS", raising=False) + else: + monkeypatch.setenv("UNISWAP_AGENT_ADDRESS", address) assert agent.main(["balance", "ETH"]) == 2 def test_parser_covers_all_commands(self): From 2cdf77f9b9b9f48d128de3bfe8dcaab42508cecd Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 27 Aug 2026 23:55:08 +0000 Subject: [PATCH 5/7] feat(skill): fold in dogfood corrections from cross-agent testing Four corrections from Gordon's dogfood run (trading-agent perspective): - Human-readable amounts: quote/swap/balance outputs now carry advisory _human + decimals_ companions (best-effort ERC-20 decimals; omitted, never fatal, when the lookup fails) - Deterministic v3-testnet refusal: v3 on testnets exits 2 (safety, don't retry) instead of surfacing the library's mainnet-hardcoded contracts as a retryable-looking runtime error - Setup docs install from the PR branch until a release contains the skill - Non-testnet broadcast is now double-armed: UNISWAP_AGENT_ALLOW_MAINNET=1 (operator standing decision) AND --allow-mainnet (per-call confirmation) Tests 18 -> 28; verified live: mainnet quote with human fields, Sepolia v3 refusal, single-arm mainnet broadcast refused. Git-Session-Id: d14a73a4-480f-4938-9c06-fcbadbfb603c --- skills/uniswap-trading/README.md | 5 + skills/uniswap-trading/SKILL.md | 26 +++- .../uniswap-trading/scripts/uniswap_agent.py | 116 ++++++++++++++++-- .../tests/test_safety_gates.py | 56 ++++++++- 4 files changed, 181 insertions(+), 22 deletions(-) diff --git a/skills/uniswap-trading/README.md b/skills/uniswap-trading/README.md index f51c5ae..9eaedb7 100644 --- a/skills/uniswap-trading/README.md +++ b/skills/uniswap-trading/README.md @@ -34,6 +34,11 @@ No network needed — tests cover the safety gates and argument surface. Skeleton (grant deliverable D3 groundwork). Verified working: v3 + v4 mainnet quotes over public RPCs, dry-run swap plans, all safety gates. +Cross-agent dogfood (Gordon, 2026-08-27): install clean, mainnet v3/v4 + +Polygon v3 quoting verified; his four corrections are folded in — human +amounts in JSON, deterministic v3-testnet refusal, branch-install setup +docs, double-armed mainnet broadcast (`UNISWAP_AGENT_ALLOW_MAINNET=1` + +`--allow-mainnet`). TODO before production: - [ ] **Sepolia enablement in the library** (blocks the D3 testnet tx demo): diff --git a/skills/uniswap-trading/SKILL.md b/skills/uniswap-trading/SKILL.md index fb63946..e4606d5 100644 --- a/skills/uniswap-trading/SKILL.md +++ b/skills/uniswap-trading/SKILL.md @@ -12,7 +12,15 @@ passed, and non-testnet chains are refused unless explicitly allowed. ## Setup -Requires `uniswap-python >= 0.8.0` (`pip install uniswap-python`) and: +Requires uniswap-python **with this skill included** — until the next PyPI +release contains it, install from the PR branch: + +```bash +pip install "uniswap-python @ git+https://github.com/TimeToBuildBob/uniswap-python.git@feat/agent-skill-skeleton" +# after the skill ships in a release: pip install "uniswap-python>=0.8.1" +``` + +Then: ```bash export PROVIDER=https://ethereum-sepolia-rpc.publicnode.com # any JSON-RPC endpoint @@ -52,11 +60,16 @@ chains pass 0x token addresses. ## Safety rules (enforced by the CLI, exit code 2 on refusal) 1. Read-only commands (status, quote, balance) work on any chain. Broadcasting - is limited to known testnets (Sepolia, Arbitrum/Base/Optimism Sepolia) - unless `UNISWAP_AGENT_ALLOW_MAINNET=1` is set. Never set that variable - yourself — ask the human operator. + outside known testnets (Sepolia, Arbitrum/Base/Optimism Sepolia) is + **double-armed**: it requires BOTH `UNISWAP_AGENT_ALLOW_MAINNET=1` in the + environment AND `--allow-mainnet` on the specific call. Never set the + environment arm yourself — that is the human operator's standing decision; + the flag is your per-call confirmation. 2. Nothing is signed or sent without `--broadcast`. 3. Slippage above 5% is refused (`UNISWAP_AGENT_MAX_SLIPPAGE` to override). +4. v3 on testnets is refused deterministically (exit 2) — the library's v3 + contracts are mainnet-hardcoded; don't retry, use a production network or + v4. ## Current network coverage (uniswap-python 0.8.0) @@ -76,4 +89,7 @@ safety guard refused (do not retry with workarounds — report to the human), retry or a different fee tier). Amounts are integers in the token's base units (wei for ETH/WETH: 1 ETH = -10^18). Convert before showing humans. +10^18). Where token decimals are readable on-chain, outputs also carry +advisory `_human` (decimal string) and `decimals_` companions — +prefer those when showing humans, but treat the integer fields as the source +of truth (the human fields are omitted when a decimals lookup fails). diff --git a/skills/uniswap-trading/scripts/uniswap_agent.py b/skills/uniswap-trading/scripts/uniswap_agent.py index 60a4b1a..4004094 100755 --- a/skills/uniswap-trading/scripts/uniswap_agent.py +++ b/skills/uniswap-trading/scripts/uniswap_agent.py @@ -7,8 +7,9 @@ Safety model (testnet-first): - Read-only commands (status, quote, balance) work on any chain. -- Broadcasting is only allowed on well-known testnets unless - ``UNISWAP_AGENT_ALLOW_MAINNET=1`` is set. +- Broadcasting outside known testnets requires BOTH arms: the + ``UNISWAP_AGENT_ALLOW_MAINNET=1`` environment opt-in AND the per-call + ``--allow-mainnet`` flag. Either alone is refused. - All state-changing commands are dry-run by default; pass ``--broadcast`` to actually sign and send. A private key is only required to broadcast. - Slippage is capped at 5% unless ``UNISWAP_AGENT_MAX_SLIPPAGE`` raises it. @@ -63,17 +64,89 @@ class SafetyError(Exception): """A guard refused the operation. Message is agent-readable.""" -def check_broadcast_chain_allowed(chain_id: int, allow_mainnet: bool) -> None: - """Gate for state-changing (broadcast) operations only.""" +def check_broadcast_chain_allowed( + chain_id: int, allow_mainnet_env: bool, allow_mainnet_flag: bool +) -> None: + """Gate for state-changing (broadcast) operations only. + + Non-testnet broadcasting is double-armed: the environment opt-in + (a standing decision by the operator) AND the per-call --allow-mainnet + flag (proof this specific call means it) must both be present. + """ if chain_id in TESTNET_CHAIN_IDS: return - if not allow_mainnet: + missing = [] + if not allow_mainnet_env: + missing.append("UNISWAP_AGENT_ALLOW_MAINNET=1 (environment)") + if not allow_mainnet_flag: + missing.append("--allow-mainnet (per-call flag)") + if missing: raise SafetyError( - f"refusing to broadcast on chain id {chain_id} (not a known testnet). " - "Set UNISWAP_AGENT_ALLOW_MAINNET=1 to trade on it deliberately." + f"refusing to broadcast on chain id {chain_id} (not a known testnet); " + f"missing: {' and '.join(missing)}. Both arms are required." ) +def check_version_supported(version: int, chain_id: int) -> None: + """Refuse deterministically where uniswap-python cannot work. + + The v3 client hardcodes mainnet quoter/router addresses, so v3 calls on + testnets fail with a confusing retryable-looking contract error. Refuse + up front (exit 2, non-retryable) instead. + """ + if version == 3 and chain_id in TESTNET_CHAIN_IDS: + raise SafetyError( + f"uniswap-python v3 support uses mainnet-hardcoded contract " + f"addresses and does not work on {TESTNET_CHAIN_IDS[chain_id]}; " + "use --version 4 once testnet enablement lands in the library, " + "or quote on a production network." + ) + + +_ERC20_DECIMALS_ABI = [ + { + "name": "decimals", + "inputs": [], + "outputs": [{"type": "uint8", "name": ""}], + "stateMutability": "view", + "type": "function", + } +] + + +def _token_decimals(w3: Any, token: str) -> Optional[int]: + """Best-effort ERC-20 decimals; None when the lookup fails.""" + if token == ETH: + return 18 + try: + contract = w3.eth.contract( + address=w3.to_checksum_address(token), abi=_ERC20_DECIMALS_ABI + ) + return int(contract.functions.decimals().call()) + except Exception: # noqa: BLE001 — decimals are advisory, never fatal + return None + + +def _humanize(amounts: dict[str, Any], w3: Any, tokens: dict[str, str]) -> None: + """Annotate an output dict with decimals + human-readable amounts. + + ``tokens`` maps an existing amount key (e.g. "qty_in") to the token + address it is denominated in. Adds ``_human`` and ``decimals_`` + fields; skips silently when decimals can't be read (advisory data must + never break the machine-readable core).""" + cache: dict[str, Optional[int]] = {} + for key, token in tokens.items(): + if token not in cache: + cache[token] = _token_decimals(w3, token) + decimals = cache[token] + if decimals is None or key not in amounts: + continue + amounts[f"decimals_{key}"] = decimals + amounts[f"{key}_human"] = f"{amounts[key] / 10**decimals:.8f}".rstrip( + "0" + ).rstrip(".") + + def check_slippage(slippage: float, ceiling: float) -> None: if not 0 < slippage <= ceiling: raise SafetyError( @@ -187,6 +260,7 @@ def cmd_status(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: def cmd_quote(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: client = _connect(cfg, args.version) chain_id = _chain_id(client) + check_version_supported(args.version, chain_id) token_in = resolve_token(args.token_in, chain_id) token_out = resolve_token(args.token_out, chain_id) if args.version == 4: @@ -199,7 +273,7 @@ def cmd_quote(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: ) else: amount_out = client.get_price_input(token_in, token_out, args.qty, fee=args.fee) - return { + result = { "chain_id": chain_id, "version": args.version, "token_in": token_in, @@ -207,6 +281,8 @@ def cmd_quote(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: "qty_in": args.qty, "amount_out": int(amount_out), } + _humanize(result, client.w3, {"qty_in": token_in, "amount_out": token_out}) + return result def cmd_swap(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: @@ -214,8 +290,9 @@ def cmd_swap(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: check_slippage(args.slippage, cfg.max_slippage) client = _connect(cfg, args.version) chain_id = _chain_id(client) + check_version_supported(args.version, chain_id) if args.broadcast: - check_broadcast_chain_allowed(chain_id, cfg.allow_mainnet) + check_broadcast_chain_allowed(chain_id, cfg.allow_mainnet, args.allow_mainnet) token_in = resolve_token(args.token_in, chain_id) token_out = resolve_token(args.token_out, chain_id) @@ -246,6 +323,11 @@ def cmd_swap(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: "slippage": args.slippage, "broadcast": args.broadcast, } + _humanize( + plan, + client.w3, + {"qty_in": token_in, "quoted_out": token_out, "min_out": token_out}, + ) if not args.broadcast: plan["note"] = "dry-run: pass --broadcast to sign and send" return plan @@ -283,7 +365,9 @@ def cmd_balance(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: balance = int(client.w3.eth.get_balance(address)) else: balance = int(client.get_token_balance(token)) - return {"chain_id": chain_id, "token": token, "balance": balance} + result = {"chain_id": chain_id, "token": token, "balance": balance} + _humanize(result, client.w3, {"balance": token}) + return result def cmd_approve(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: @@ -291,7 +375,7 @@ def cmd_approve(cfg: Config, args: argparse.Namespace) -> dict[str, Any]: client = _connect(cfg, args.version) chain_id = _chain_id(client) if args.broadcast: - check_broadcast_chain_allowed(chain_id, cfg.allow_mainnet) + check_broadcast_chain_allowed(chain_id, cfg.allow_mainnet, args.allow_mainnet) token = resolve_token(args.token, chain_id) if not args.broadcast: return { @@ -333,6 +417,11 @@ def build_parser() -> argparse.ArgumentParser: p_swap.add_argument("--tick-spacing", type=int, default=60, help="v4 only") p_swap.add_argument("--slippage", type=float, default=0.01) p_swap.add_argument("--broadcast", action="store_true", help="sign and send") + p_swap.add_argument( + "--allow-mainnet", + action="store_true", + help="second arm (with UNISWAP_AGENT_ALLOW_MAINNET=1) for non-testnet broadcast", + ) p_bal = sub.add_parser("balance", help="wallet balance of a token") p_bal.add_argument("token") @@ -340,6 +429,11 @@ def build_parser() -> argparse.ArgumentParser: p_appr = sub.add_parser("approve", help="approve the router for a token") p_appr.add_argument("token") p_appr.add_argument("--broadcast", action="store_true", help="sign and send") + p_appr.add_argument( + "--allow-mainnet", + action="store_true", + help="second arm (with UNISWAP_AGENT_ALLOW_MAINNET=1) for non-testnet broadcast", + ) return parser diff --git a/skills/uniswap-trading/tests/test_safety_gates.py b/skills/uniswap-trading/tests/test_safety_gates.py index 7d4ec28..ee48648 100644 --- a/skills/uniswap-trading/tests/test_safety_gates.py +++ b/skills/uniswap-trading/tests/test_safety_gates.py @@ -14,19 +14,63 @@ class TestBroadcastChainGate: - def test_sepolia_allowed(self): - agent.check_broadcast_chain_allowed(11155111, allow_mainnet=False) + def test_sepolia_allowed_without_arms(self): + agent.check_broadcast_chain_allowed(11155111, False, False) def test_mainnet_refused_by_default(self): with pytest.raises(agent.SafetyError, match="not a known testnet"): - agent.check_broadcast_chain_allowed(1, allow_mainnet=False) + agent.check_broadcast_chain_allowed(1, False, False) - def test_mainnet_allowed_with_optin(self): - agent.check_broadcast_chain_allowed(1, allow_mainnet=True) + def test_mainnet_env_alone_refused(self): + with pytest.raises(agent.SafetyError, match="--allow-mainnet"): + agent.check_broadcast_chain_allowed(1, True, False) + + def test_mainnet_flag_alone_refused(self): + with pytest.raises(agent.SafetyError, match="UNISWAP_AGENT_ALLOW_MAINNET"): + agent.check_broadcast_chain_allowed(1, False, True) + + def test_mainnet_allowed_with_both_arms(self): + agent.check_broadcast_chain_allowed(1, True, True) def test_unknown_chain_refused(self): with pytest.raises(agent.SafetyError): - agent.check_broadcast_chain_allowed(56, allow_mainnet=False) + agent.check_broadcast_chain_allowed(56, True, False) + + +class TestVersionGate: + def test_v3_on_sepolia_refused_deterministically(self): + with pytest.raises(agent.SafetyError, match="mainnet-hardcoded"): + agent.check_version_supported(3, 11155111) + + def test_v3_on_mainnet_ok(self): + agent.check_version_supported(3, 1) + + def test_v3_on_polygon_ok(self): + agent.check_version_supported(3, 137) + + def test_v4_on_sepolia_ok(self): + agent.check_version_supported(4, 11155111) + + +class TestHumanize: + class _FailingW3: + class eth: # noqa: N801 — mimics web3 attribute shape + @staticmethod + def contract(*a, **k): + raise RuntimeError("no network in tests") + + def test_eth_gets_18_decimals_offline(self): + out = {"qty_in": 1500000000000000000} + agent._humanize(out, self._FailingW3(), {"qty_in": agent.ETH}) + assert out["decimals_qty_in"] == 18 + assert out["qty_in_human"] == "1.5" + + def test_unknown_decimals_skips_silently(self): + out = {"amount_out": 123} + token = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + agent._humanize(out, self._FailingW3(), {"amount_out": token}) + assert "amount_out_human" not in out + assert out["amount_out"] == 123 class TestSlippageGate: From 668418bacd5cc2bb265bc01d9718e2b7a6814c0a Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 15 Oct 2026 02:15:29 +0000 Subject: [PATCH 6/7] =?UTF-8?q?ci:=20retrigger=20CI=20=E2=80=94=20previous?= =?UTF-8?q?=20run=20failed=20on=20transient=20eth.drpc.org=20RPC=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git-Session-Id: 4dfa6bdf-a702-43e1-b0bf-4a7e07461cdb From 25ec81e4eebaf796213c3931c58c5e868e97e6c8 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 15 Oct 2026 02:37:37 +0000 Subject: [PATCH 7/7] =?UTF-8?q?ci:=20upgrade=20codecov-action=20v4?= =?UTF-8?q?=E2=86=92v5,=20set=20fail=5Fci=5Fif=5Ferror=3Dfalse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v4 uses deprecated Node.js 20 (forced to Node.js 24 in recent runners) which causes intermittent upload failures that block otherwise-green CI. v5 is the supported release. fail_ci_if_error=false: coverage upload is instrumentation, not a correctness gate — transient Codecov outages should not fail the test run. Git-Session-Id: 80b13aa8-1c0d-4abb-82f2-2eda9d90539b --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a4dff2..0f764b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,10 +80,10 @@ jobs: make test - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + fail_ci_if_error: false typecheck: name: typecheck