Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
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
  <field>_human + decimals_<field> 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.
  • Loading branch information
TimeToBuildBob committed Aug 27, 2026
commit 3bcc0c20cbaa117f1101e0a2596a79ff5e1abcff
5 changes: 5 additions & 0 deletions skills/uniswap-trading/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
26 changes: 21 additions & 5 deletions skills/uniswap-trading/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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 `<field>_human` (decimal string) and `decimals_<field>` 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).
116 changes: 105 additions & 11 deletions skills/uniswap-trading/scripts/uniswap_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ``<key>_human`` and ``decimals_<key>``
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(
Expand Down Expand Up @@ -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:
Expand All @@ -199,23 +273,26 @@ 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,
"token_out": token_out,
"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]:
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)
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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -283,15 +365,17 @@ 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))
Comment thread
TimeToBuildBob marked this conversation as resolved.
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]:
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)
check_broadcast_chain_allowed(chain_id, cfg.allow_mainnet, args.allow_mainnet)
token = resolve_token(args.token, chain_id)
if not args.broadcast:
return {
Expand Down Expand Up @@ -333,13 +417,23 @@ 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")

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

Expand Down
56 changes: 50 additions & 6 deletions skills/uniswap-trading/tests/test_safety_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading