diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..968bcb3 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# x402 Payment Configuration +# Copy this to .env and fill in your values + +# Your Injective wallet address to receive x402 payments +# Required for payment-protected endpoints +X402_PAY_TO_ADDRESS= + +# x402 facilitator URL (required) +# The public x402.org facilitator is testnet-only and does NOT support +# Injective mainnet (eip155:1776). Provide an Injective-compatible +# production facilitator, or run/self-facilitate your own. +X402_FACILITATOR_URL= + +# OpenAI API key (required for iAgent chat functionality) +OPENAI_API_KEY= diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..604e1ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GenTech Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc48e5d --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# iAgent × x402 — Pay-Per-Call AI Agent for Injective Chain + +Add x402 payment support to Injective's [iAgent](https://github.com/InjectiveLabs/iAgent) — an AI agent fine-tuned on Injective trading. + +## What This Does + +Wraps iAgent's chat and history endpoints with **x402 pay-per-call middleware**. Agents pay $0.01 per chat call and $0.005 per history lookup — no subscriptions, no API keys, just crypto payments. + +## Features + +- **x402 v2 payment middleware** — ASGI-compatible, works with Quart/Hypercorn +- **Bazaar discovery** — `/.well-known/x402-bazaar` for automated agent discovery +- **Pricing endpoint** — `GET /pricing` lists all paid endpoints +- **Input validation** — EVM address format + HTTPS URL validation +- **Fail closed** — No default facilitator, must be explicitly configured + +## Quick Start + +```bash +# Install dependencies +pip install x402 python-dotenv quart hypercorn + +# Configure +cp .env.example .env +# Edit .env with your Injective EVM address and facilitator URL + +# Run with x402 enabled +python agent_server.py --x402 +``` + +## Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `X402_PAY_TO_ADDRESS` | Yes | — | Your Injective EVM address (0x...) | +| `X402_FACILITATOR_URL` | Yes | — | x402 facilitator HTTPS URL | +| `OPENAI_API_KEY` | Yes | — | OpenAI API key for iAgent chat | + +## Endpoints + +| Method | Path | Price | Description | +|--------|------|-------|-------------| +| GET | `/pricing` | Free | List all paid endpoints | +| GET | `/.well-known/x402-bazaar` | Free | x402 discovery metadata | +| POST | `/chat` | $0.01 | Chat with Injective AI agent | +| GET | `/history` | $0.005 | View chat history | +| GET | `/ping` | Free | Health check | + +## Testing + +```bash +python3 -m pytest tests/ -v +``` + +## Architecture + +``` +Agent → x402 Payment → iAgent Server → Injective Chain + Middleware (Quart) + │ + ▼ + x402 Facilitator + (verifies payment) +``` + +## License + +MIT — see [LICENSE](LICENSE) + +## Related + +- [Injective iAgent](https://github.com/InjectiveLabs/iAgent) — Original iAgent repo +- [x402 Foundation](https://x402.org) — x402 payment protocol +- [GenTech Labs](https://gentechlabs.net) — Our x402 gateway diff --git a/tests/test_x402_middleware.py b/tests/test_x402_middleware.py new file mode 100644 index 0000000..c3c087a --- /dev/null +++ b/tests/test_x402_middleware.py @@ -0,0 +1,226 @@ +""" +Tests for iAgent × x402 Payment Middleware +=========================================== +Tests the x402 middleware module in isolation (no real x402 facilitator). +Uses Quart test client for ASGI endpoint testing. +""" + +import os +import sys +import json +import pytest + +# Ensure the repo root is on the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from quart import Quart +from x402_middleware import ( + wrap_with_x402, + add_discovery_routes, + load_x402_config, + _valid_evm_address, + _valid_https_url, + CHAT_PRICE_USD, + HISTORY_PRICE_USD, + INJECTIVE_NETWORK, +) + +# Valid test addresses +TEST_EVM_ADDRESS = "0x7ebff188f2Eba16518C02864589b1403a5d1296a" +TEST_FACILITATOR_URL = "https://test-facilitator.example.com" + + +# ── Fixtures ────────────────────────────────────────────────────────── + +@pytest.fixture +def app(): + """Create a fresh Quart app for each test.""" + qapp = Quart(__name__) + + @qapp.route("/ping") + async def ping(): + return {"status": "ok"} + + @qapp.route("/chat", methods=["POST"]) + async def chat(): + return {"response": "test response", "session_id": "test"} + + @qapp.route("/history", methods=["GET"]) + async def history(): + return {"history": []} + + return qapp + + +@pytest.fixture +def configured_app(app): + """App with x402 middleware configured.""" + os.environ["X402_PAY_TO_ADDRESS"] = TEST_EVM_ADDRESS + os.environ["X402_FACILITATOR_URL"] = TEST_FACILITATOR_URL + return app + + +# ── Address Validation Tests ────────────────────────────────────────── + +def test_valid_evm_address(): + """Valid 0x-prefixed EVM addresses pass validation.""" + assert _valid_evm_address("0x7ebff188f2Eba16518C02864589b1403a5d1296a") is True + assert _valid_evm_address("0x0000000000000000000000000000000000000000") is True + assert _valid_evm_address("0xABCDEFabcdef0123456789ABCDEFabcdef012345") is True + + +def test_invalid_evm_address(): + """Invalid addresses fail validation.""" + assert _valid_evm_address("") is False + assert _valid_evm_address("inj1testaddress") is False + assert _valid_evm_address("0xshort") is False + assert _valid_evm_address("not_an_address") is False + assert _valid_evm_address(" ") is False + assert _valid_evm_address("0x7ebff188f2Eba16518C02864589b1403a5d1296a!") is False + + +# ── URL Validation Tests ────────────────────────────────────────────── + +def test_valid_https_url(): + """Valid HTTPS URLs pass validation.""" + assert _valid_https_url("https://x402.org/facilitator") is True + assert _valid_https_url("https://facilitator.example.com:8443/path") is True + assert _valid_https_url("https://localhost:8080") is True + + +def test_invalid_https_url(): + """Non-HTTPS URLs fail validation.""" + assert _valid_https_url("http://x402.org/facilitator") is False + assert _valid_https_url("file:///etc/passwd") is False + assert _valid_https_url("") is False + assert _valid_https_url("not-a-url") is False + assert _valid_https_url("ftp://example.com") is False + + +# ── Config Tests ────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch): + """Isolate config tests from any local .env file. + + load_x402_config() calls load_dotenv(), which can restore variables from a + developer's local .env and make these tests flaky. Clear both variables and + neutralize load_dotenv so each test validates unconfigured behavior. + """ + monkeypatch.delenv("X402_PAY_TO_ADDRESS", raising=False) + monkeypatch.delenv("X402_FACILITATOR_URL", raising=False) + import x402_middleware + monkeypatch.setattr(x402_middleware, "load_dotenv", lambda *a, **k: None) + + +def test_load_x402_config_missing(): + """Returns False when X402_PAY_TO_ADDRESS is not set.""" + assert load_x402_config() is False + + +def test_load_x402_config_invalid_address(): + """Returns False when address is not a valid EVM address.""" + os.environ["X402_PAY_TO_ADDRESS"] = "inj1invalid" + os.environ["X402_FACILITATOR_URL"] = TEST_FACILITATOR_URL + assert load_x402_config() is False + + +def test_load_x402_config_missing_facilitator(): + """Returns False when facilitator URL is not set.""" + os.environ["X402_PAY_TO_ADDRESS"] = TEST_EVM_ADDRESS + assert load_x402_config() is False + + +def test_load_x402_config_present(): + """Returns True when valid config is set.""" + os.environ["X402_PAY_TO_ADDRESS"] = TEST_EVM_ADDRESS + os.environ["X402_FACILITATOR_URL"] = TEST_FACILITATOR_URL + assert load_x402_config() is True + + +# ── Middleware Wrapping Tests ──────────────────────────────────────── + +def test_wrap_with_x402_no_config(app): + """Returns None when x402 is not configured.""" + result = wrap_with_x402(app) + assert result is None + + +def test_wrap_with_x402_configured(configured_app): + """Returns middleware instance when configured.""" + result = wrap_with_x402(configured_app) + assert result is not None + + +# ── Discovery Endpoint Tests ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_pricing_endpoint(): + """GET /pricing returns expected pricing structure.""" + app = Quart(__name__) + add_discovery_routes(app) + + test_client = app.test_client() + async with test_client: + response = await test_client.get("/pricing") + assert response.status_code == 200 + data = await response.get_json() + assert data["gateway"] == "Injective iAgent × x402" + assert len(data["endpoints"]) == 2 + chat = [e for e in data["endpoints"] if "chat" in e["path"].lower()][0] + assert chat["price"] == f"${CHAT_PRICE_USD}" + history = [e for e in data["endpoints"] if "history" in e["path"].lower()][0] + assert history["price"] == f"${HISTORY_PRICE_USD}" + + +@pytest.mark.asyncio +async def test_bazaar_discovery_endpoint(): + """GET /.well-known/x402-bazaar returns x402 v2 discovery metadata.""" + os.environ["X402_PAY_TO_ADDRESS"] = TEST_EVM_ADDRESS + os.environ["X402_FACILITATOR_URL"] = TEST_FACILITATOR_URL + load_x402_config() + + app = Quart(__name__) + add_discovery_routes(app) + + test_client = app.test_client() + async with test_client: + response = await test_client.get("/.well-known/x402-bazaar") + assert response.status_code == 200 + data = await response.get_json() + assert data["x402Version"] == 2 + assert "Injective iAgent" in data["gateway"] + assert len(data["endpoints"]) == 2 + assert len(data["facilitators"]) == 1 + assert data["facilitators"][0]["name"] == "x402 Foundation Facilitator" + + +@pytest.mark.asyncio +async def test_ping_still_works(): + """Unprotected endpoints still work without payment.""" + app = Quart(__name__) + + @app.route("/ping") + async def ping(): + return {"status": "ok"} + + test_client = app.test_client() + async with test_client: + response = await test_client.get("/ping") + assert response.status_code == 200 + data = await response.get_json() + assert data["status"] == "ok" + + +# ── Network Configuration Tests ─────────────────────────────────────── + +def test_injective_network(): + """Injective network is configured as EVM-compatible chain ID 1776.""" + assert INJECTIVE_NETWORK == "eip155:1776" + + +def test_pricing_constants(): + """Pricing constants are reasonable positive values.""" + assert float(CHAT_PRICE_USD) > 0 + assert float(HISTORY_PRICE_USD) > 0 + assert float(CHAT_PRICE_USD) > float(HISTORY_PRICE_USD) diff --git a/x402_middleware.py b/x402_middleware.py new file mode 100644 index 0000000..52a445b --- /dev/null +++ b/x402_middleware.py @@ -0,0 +1,250 @@ +""" +iAgent × x402 Payment Middleware +================================= +Adds x402 pay-per-call support to Injective's iAgent server. + +Based on GenTech Labs' production x402 gateway pattern. +Uses the x402 Python SDK v2 for ASGI-compatible payment middleware. + +Run: + pip install x402 python-dotenv + cp .env.example .env # fill in your addresses + python agent_server.py --x402 +""" + +import os +import re +import logging +from typing import Optional + +from dotenv import load_dotenv +from x402.http import FacilitatorConfig, HTTPFacilitatorClient, PaymentOption +from x402.http.types import RouteConfig, RoutesConfig +from x402.http.middleware.fastapi import PaymentMiddlewareASGI +from x402.mechanisms.evm.exact import ExactEvmServerScheme +from x402.schemas import Network +from x402.server import x402ResourceServer +from x402.extensions.bazaar import declare_discovery_extension, OutputConfig + +logger = logging.getLogger(__name__) + +# ── Compiled Patterns ──────────────────────────────────────────────── + +_EVM_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") +_HTTPS_URL_RE = re.compile(r"^https://[a-zA-Z0-9][a-zA-Z0-9.-]+[a-zA-Z0-9](:\d+)?(/.*)?$") + +# ── Configuration (loaded from env) ───────────────────────────────── + +INJECTIVE_ADDRESS: Optional[str] = None +INJECTIVE_NETWORK: Network = "eip155:1776" # Injective EVM mainnet +X402_FACILITATOR_URL: Optional[str] = None + +# Default pricing +CHAT_PRICE_USD = "0.01" # $0.01 per chat call +HISTORY_PRICE_USD = "0.005" # $0.005 per history lookup + + +def _valid_evm_address(addr: str) -> bool: + """Validate a 0x-prefixed EVM address (40 hex chars).""" + return bool(_EVM_ADDRESS_RE.match(addr.strip())) + + +def _valid_https_url(url: str) -> bool: + """Validate an HTTPS URL (no file://, no http:// except localhost).""" + stripped = url.strip() + if not _HTTPS_URL_RE.match(stripped): + return False + return True + + +def load_x402_config() -> bool: + """Load x402 configuration from environment. Returns True if configured.""" + load_dotenv() + global INJECTIVE_ADDRESS, X402_FACILITATOR_URL + + raw_address = os.getenv("X402_PAY_TO_ADDRESS", "") + raw_facilitator = os.getenv("X402_FACILITATOR_URL", "") + + if not raw_address or not raw_address.strip(): + logger.warning( + "x402 not configured: X402_PAY_TO_ADDRESS not set. " + "Set it in .env to enable payment-protected endpoints." + ) + return False + + if not _valid_evm_address(raw_address): + logger.error( + f"Invalid X402_PAY_TO_ADDRESS format: expected 0x-prefixed EVM address " + f"(40 hex chars), got '{raw_address[:20]}...'" + ) + return False + + if not raw_facilitator or not raw_facilitator.strip(): + logger.error( + "x402 not configured: X402_FACILITATOR_URL not set. " + "Must be set to an HTTPS facilitator URL." + ) + return False + + if not _valid_https_url(raw_facilitator): + logger.error( + f"Invalid X402_FACILITATOR_URL: must be HTTPS URL, " + f"got '{raw_facilitator[:40]}...'" + ) + return False + + INJECTIVE_ADDRESS = raw_address.strip() + X402_FACILITATOR_URL = raw_facilitator.strip() + return True + + +def wrap_with_x402(app) -> Optional[PaymentMiddlewareASGI]: + """Wrap a Quart/ASGI app with x402 payment middleware. + + Returns the middleware instance if configured, None otherwise. + """ + if not load_x402_config(): + return None + + facilitator = HTTPFacilitatorClient( + FacilitatorConfig(url=str(X402_FACILITATOR_URL)) + ) + + server = x402ResourceServer(facilitator) + server.register(INJECTIVE_NETWORK, ExactEvmServerScheme()) + + routes: RoutesConfig = { + "POST /chat": RouteConfig( + accepts=[ + PaymentOption( + scheme="exact", + pay_to=str(INJECTIVE_ADDRESS), + price=f"${CHAT_PRICE_USD}", + network=INJECTIVE_NETWORK, + ), + ], + mime_type="application/json", + description="Chat with Injective AI agent — trading, balances, staking", + extensions=declare_discovery_extension( + input={"message": "What is my INJ balance?"}, + input_schema={ + "type": "object", + "properties": { + "message": {"type": "string", "description": "User prompt for the agent"} + }, + "required": ["message"], + }, + body_type="json", + output=OutputConfig( + example={"response": "Your INJ balance is 12.5 INJ", "session_id": "abc123"} + ), + ), + ), + "GET /history": RouteConfig( + accepts=[ + PaymentOption( + scheme="exact", + pay_to=str(INJECTIVE_ADDRESS), + price=f"${HISTORY_PRICE_USD}", + network=INJECTIVE_NETWORK, + ), + ], + mime_type="application/json", + description="View chat history for a session", + extensions=declare_discovery_extension( + input={"session_id": "abc123"}, + input_schema={ + "type": "object", + "properties": { + "session_id": {"type": "string", "description": "Session identifier"} + }, + "required": ["session_id"], + }, + output=OutputConfig( + example={"history": [{"role": "user", "content": "hi"}]} + ), + ), + ), + } + + middleware = PaymentMiddlewareASGI( + app=app, + routes=routes, + server=server, + ) + + logger.info( + "x402 payment middleware attached: " + f"POST /chat (${CHAT_PRICE_USD}), " + f"GET /history (${HISTORY_PRICE_USD})" + ) + return middleware + + +# ── Unprotected Discovery Endpoints ────────────────────────────────── + +def add_discovery_routes(app) -> None: + """Add x402 discovery endpoints (no payment required).""" + + @app.route("/pricing") + async def pricing(): + """List all paid endpoints and their prices.""" + return { + "gateway": "Injective iAgent × x402", + "endpoints": [ + { + "path": "POST /chat", + "price": f"${CHAT_PRICE_USD}", + "network": str(INJECTIVE_NETWORK), + "description": "Chat with Injective AI agent", + }, + { + "path": "GET /history", + "price": f"${HISTORY_PRICE_USD}", + "network": str(INJECTIVE_NETWORK), + "description": "View chat history", + }, + ], + } + + @app.route("/.well-known/x402-bazaar") + async def bazaar_discovery(): + """Bazaar discovery endpoint for automated agent discovery.""" + return { + "x402Version": 2, + "gateway": "Injective iAgent × x402", + "description": "AI agent for Injective Chain trading, balances, and staking — pay per call via x402", + "endpoints": [ + { + "path": "POST /chat", + "description": "Chat with Injective AI agent", + "accepts": [ + { + "scheme": "exact", + "price": f"${CHAT_PRICE_USD}", + "network": str(INJECTIVE_NETWORK), + "payTo": INJECTIVE_ADDRESS, + } + ], + }, + { + "path": "GET /history", + "description": "View chat history", + "accepts": [ + { + "scheme": "exact", + "price": f"${HISTORY_PRICE_USD}", + "network": str(INJECTIVE_NETWORK), + "payTo": INJECTIVE_ADDRESS, + } + ], + }, + ], + "facilitators": [ + { + "name": "x402 Foundation Facilitator", + "url": X402_FACILITATOR_URL, + "chains": ["eip155:*"], + }, + ], + }