DEV Community

Nikhil Ranka
Nikhil Ranka

Posted on

How I Built an Autonomous AI Agent That Earns USDC While I Sleep

How I Built an Autonomous AI Agent That Earns USDC While I Sleep

Target audience: developers who are designing or already running autonomous agents and want to understand a pragmatic, production‑oriented approach to monetising those agents with crypto‑native micro‑payments.


1. Why bother with on‑chain payments?

When an agent can be called by anyone, the simplest way to recoup compute cost is to charge per invocation. Traditional APIs rely on API keys, rate‑limits, and invoicing pipelines that add operational overhead. The x402 protocol lets you attach a payment‑required HTTP header to any endpoint; the caller must settle a tiny amount of USDC before the request is processed. On Base (an Optimistic Rollup backed by Ethereum) a single USDC transfer costs a few fractions of a cent, making it feasible to price calls at $0.01‑$0.10 without worrying about chargebacks or fraud.

The trade‑off is added latency (the payment must confirm) and the need to handle wallet keys securely. If your agent’s workload is latency‑sensitive (sub‑second response), you may prefer off‑chain billing; otherwise, x402 gives you a trustless, programmable revenue stream.


2. High‑level architecture

+-------------------+      +-------------------+      +-------------------+
|   Caller (dApp)   | ---> |  x402 Gateway     | ---> |   Agent Worker    |
|   (wallet + USDC) |      | (CF Worker)       |      | (Python + LLM)    |
+-------------------+      +-------------------+      +-------------------+
          ^                         |                         |
          |                         v                         v
          |               +-------------------+   +-------------------+
          |               |  Payment Verifier |   |  Tool Orchestrator|
          |               +-------------------+   +-------------------+
          |                         |                         |
          +-------------------------+-------------------------+
                                    |
                            +-------------------+
                            |  Result Cache (KV)|
                            +-------------------+
Enter fullscreen mode Exit fullscreen mode
  • x402 Gateway – a thin Cloudflare Workers script that reads the Payment header, validates the USDC transfer on Base, and either returns 402 Payment Required or forwards the request to the worker.
  • Agent Worker – a long‑running serverless function (Python) that loads a lightweight LLM (e.g., Llama‑3‑8B quantised via llama.cpp), decides which tool to invoke, runs the tool, and returns the result.
  • Tool Orchestrator – a plug‑in system that wraps deterministic APIs (price feeds, web scrapers, calculators) as callable functions.
  • Result Cache – optional KV store to avoid re‑computing identical queries; cache TTL is set to a few minutes to keep data fresh.

3. Payment verification in the gateway

The gateway needs to confirm that a USDC transfer of at least the required amount arrived from the caller’s address to the agent’s treasury address. On Base we can query the USDC contract’s Transfer events via an RPC endpoint (e.g., Alchemy or a public Base node).

// x402-gateway.js  (Cloudflare Workers)
import { ethers } from "https://esm.sh/ethers@6";

const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base USDC
const TREASURY    = "0xYourAgentTreasuryAddress";
const RPC_URL     = "https://base.mainnet.rpc.async.ag"; // replace with your node

export default {
  async fetch(request, env, ctx) {
    // 1️⃣ Read payment header:  "Payment: <amount>USDC:<tokenAddr>"
    const payment = request.headers.get("Payment");
    if (!payment) {
      return new Response("402 Payment Required", { status: 402, headers: { "Payment": `Price: 0.01USDC:${USDC_ADDRESS}` } });
    }
    const [amountStr, , tokenAddr] = payment.split(":");
    const amount = ethers.parseUnits(amountStr, 6); // USDC has 6 decimals
    if (tokenAddr.toLowerCase() !== USDC_ADDRESS.toLowerCase() || amount < ethers.parseUnits("0.01", 6)) {
      return new Response("Insufficient payment", { status: 402 });
    }

    // 2️⃣ Verify that the sender actually sent this amount
    const sender = request.headers.get("x402-payer"); // injected by the x402 client library
    if (!sender) return new Response("Missing payer header", { status: 400 });

    const provider = new ethers.JsonRpcProvider(RPC_URL);
    const usdc = new ethers.Contract(USDC_ADDRESS, ["event Transfer(address indexed from, address indexed to, uint256 value)"], provider);

    // Look at the latest block (could be optimized with a filter)
    const latest = await provider.getBlockNumber();
    const fromBlock = Math.max(0, latest - 200); // check last ~200 blocks (~1 minute on Base)
    const filter = usdc.filters.Transfer(null, TREASURY);
    const logs = await usdc.queryFilter(filter, fromBlock, latest);

    const paid = logs.some(l => {
      return l.args.from.toLowerCase() === sender.toLowerCase() &&
             l.args.value >= amount;
    });

    if (!paid) return new Response("Payment not verified", { status: 402 });

    // 3️⃣ Forward to the actual agent worker (same service, different path)
    return await env.AGENT.fetch(request);
  }
};
Enter fullscreen mode Exit fullscreen mode

Honest notes

  • Scanning the last 200 blocks adds ~200 ms latency on Base; you can reduce it by running a dedicated indexer or using The Graph, but that adds operational complexity.
  • The gateway stores no private keys; only the treasury address is needed to verify inbound transfers, which limits attack surface.
  • If the payer uses a contract wallet that forwards USDC via a fallback, the simple Transfer check may fail; you’d need to support ERC‑20 approve/transferFrom patterns.

4. The agent worker – core loop

The worker is a lightweight Python service that can be deployed as a Cloudflare Worker (via workers-python) or an AWS Lambda. Below is a minimal, functional version that uses a quantised Llama‑3 model hosted on Hugging Face’s Inference API (you could swap this for a local llama.cpp binary if you prefer GPU‑free execution).


python
# agent_worker.py
import json, os, time
from typing import Dict, Any
from openai import OpenAI  # compatible with Hugging Face TGI endpoints

# ---- Configuration -------------------------------------------------
HF_API_URL = os.getenv("HF_API_URL", "https://api-inference.huggingface.co/models/meta-llama/Llama-3-8b-instruct")
HF_TOKEN   = os.getenv("HF_TOKEN")          # private, never exposed to clients
SYSTEM_PROMPT = ("You are a helpful agent that can answer questions, "
                 "summarise text, and call external tools when needed.")
# -------------------------------------------------------------------

client = OpenAI(base_url=HF_API_URL, api_key=HF_TOKEN)

# Example tool: a simple price fetcher for ETH/USD via CoinGecko
def get_eth_price() -> float:
    import requests
    r = requests.get("https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd", timeout=5)
    r.raise_for_status()
    return r.json()["ethereum"]["usd"]

TOOLS = {
    "get_eth_price": get_eth_price,
    # add more tools: web_search, calculator, etc.
}

def call_tool(name: str, args: Dict[str, Any]) -> Any:
    if name not in TOOLS:
        raise ValueError(f"Unknown tool {name}")
    return TOOLS[name](**args)

def agent_loop(user_input: str) -> str:
    # 1️⃣ Ask the LLM to decide whether to use a tool
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user",   "content": user_input}
    ]
    resp = client.chat.completions.create(
        model="meta-llama/Llama-3-8b-instruct",
        messages=messages,
        temperature=0.2,
        max_tokens=256,
        tools=[{"type": "function", "function": {"name": "get_eth_price",
                                                 "description": "Return current ETH/USD price",
                                                 "parameters": {"type": "object", "properties": {}}}}],
        tool_choice="auto"
    )
    msg = resp.choices[0].message

    # 2️⃣ If the model wants a tool, execute it and feed the result back
    if msg.tool_calls:
        for call in msg.tool_calls:
            if call.function.name == "get_eth_price":
                result = call_tool("get_eth_price", {})
                messages.append({"role": "tool", "tool_call_id": call.id, "content": str(result)})
        # second pass to produce final answer
        final = client.chat
Enter fullscreen mode Exit fullscreen mode

Top comments (0)