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 experimenting with autonomous agents and want to understand the practical pieces—payment integration, task execution, deployment, and the trade‑offs involved.


1. Why an “earning while sleeping” agent?

The idea isn’t magic; it’s a simple loop:

  1. Expose a paid API endpoint (via the x402 micropayment spec).
  2. Wait for a caller to send USDC to unlock the endpoint.
  3. Run a deterministic piece of work (e.g., a small ML inference, data transformation, or validation).
  4. Return the result and keep the USDC that was paid.

If the work is cheap enough to execute on a low‑cost compute node and the price you set covers your infrastructure plus a modest margin, the agent can run unattended. The “while I sleep” part is just the agent staying online 24/7; there is no guarantee of profit, only the mechanics that make it possible.


2. High‑level architecture

+----------------+      USDC (Base)      +-------------------+
|  Caller (client) |  <--- x402 payment ---> |  Agent Service    |
+----------------+                         +-------------------+
        ^                                          |
        |                                          v
   (HTTP request)                         +-----------------+
                                          |  Task Executor  |
                                          +-----------------+
Enter fullscreen mode Exit fullscreen mode
  • Caller – any HTTP client that knows the agent’s public URL and is willing to pay a tiny amount of USDC to invoke it.
  • Agent Service – a thin HTTP front‑end that enforces the x402 payment, extracts the payload, and hands it to the executor.
  • Task Executor – the actual AI/workload code (in my example a small sentiment‑analysis model).

All components run on a single cheap VPS or a serverless platform (I used Cloudflare Workers for the front‑end and a lightweight Python script on a $5/mo VPS for the executor). The split lets me keep the payment verification latency low (Workers edge) while still having access to a GPU/CPU for the model if needed.


3. Payment integration with x402

The x402 spec defines a simple HTTP header‑based workflow:

  1. Agent responds 402 Payment Required with a payment header that contains a payment request (including the USDC contract address, amount, and a reference ID).
  2. Caller pays the requested USDC amount onchain (Base) and includes the transaction hash in the X-Payment-Tx header on the retry.
  3. Agent verifies the transaction (confirmations, correct amount, correct recipient) and, if valid, processes the request.

Below is the minimal verification logic I used in Python (web3.py). It runs inside the Worker via a small WASM‑compiled version of web3, but the same code works on any Python runtime.

# payment_verifier.py
from web3 import Web3
import os
import time

# Base mainnet RPC (free tier from Infura/Alchemy works)
RPC_URL = os.getenv("BASE_RPC", "https://base-mainnet.g.alchemy.com/v2/<key>")
w3 = Web3(Web3.HTTPProvider(RPC_URL))

USDC_ADDRESS = Web3.to_checksum_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")  # USDC on Base
AGENT_WALLET = Web3.to_checksum_address(os.getenv("AGENT_WALLET"))  # where we receive funds
REQUIRED_CONFIRMATIONS = 1

def verify_payment(tx_hash: str, expected_amount_wei: int) -> bool:
    """
    Returns True if the transaction:
      - exists,
      - sent USDC to AGENT_WALLET,
      - value == expected_amount_wei,
      - has at least REQUIRED_CONFIRMATIONS confirmations.
    """
    try:
        tx = w3.eth.get_transaction(tx_hash)
        if tx is None:
            return False
        # Check recipient and token (ERC20 transfer)
        if tx["to"].lower() != USDC_ADDRESS.lower():
            return False
        # Decode ERC20 transfer data (simplified: assumes `transfer(address,uint256)`)
        if tx["input"][:10] != "0xa9059cbb":  # function selector for transfer
            return False
        # Extract `to` and `value` from calldata
        recv = "0x" + tx["input"][34:74]
        value_hex = tx["input"][74:]
        value = int(value_hex, 16)
        if recv.lower() != AGENT_WALLET.lower() or value != expected_amount_wei:
            return False
        # Confirmation check
        latest = w3.eth.block_number
        confirmations = latest - tx["blockNumber"] + 1
        return confirmations >= REQUIRED_CONFIRMATIONS
    except Exception:
        return False
Enter fullscreen mode Exit fullscreen mode

How the front‑end uses it (pseudo‑code for a Cloudflare Worker):

// worker.js
export default {
  async fetch(request, env) {
    const { pathname } = new URL(request.url);
    if (pathname !== "/infer") {
      return new Response("Not found", { status: 404 });
    }

    // 1️⃣ Check for payment header
    const paymentReq = request.headers.get("X-Payment-Req");
    if (!paymentReq) {
      // No payment yet → ask for it
      const amount = 5_000_000; // 0.005 USDC (6 decimals) → 5000 micro‑USDC
      const headers = {
        "Payment": JSON.stringify({
          scheme: "exact",
          network: "base",
          asset: USDC_ADDRESS,
          amount: amount.toString(),
          description: "Sentiment inference",
        }),
      };
      return new Response("Payment required", { status: 402, headers });
    }

    // 2️⃣ Parse payment request to get expected amount
    const reqObj = JSON.parse(paymentReq);
    const expectedAmount = BigInt(reqObj.amount); // still in USDC's smallest unit

    // 3️⃣ Pull transaction hash from retry header
    const txHash = request.headers.get("X-Payment-Tx");
    if (!txHash) {
      return new Response("Missing tx hash", { status: 400 });
    }

    // 4️⃣ Verify via a bound Python worker (or call an external verifier)
    const verified = await env.VERIFIER.verify(txHash, expectedAmount.toString());
    if (!verified) {
      return new Response("Invalid or insufficient payment", { status: 402 });
    }

    // 5️⃣ At this point we have a valid payment → run the task
    const payload = await request.json(); // { text: "..."}
    const result = await env.EXECUTOR.fetch("/run", {
      method: "POST",
      body: JSON.stringify(payload),
    });
    return result;
  },
};
Enter fullscreen mode Exit fullscreen mode

Trade‑offs observed

Aspect What I chose Why Drawback
Payment verification On‑chain verification via a lightweight Python script (called from the Worker using a sub‑request). Guarantees trustlessness; no need to rely on a third‑party escrow. Adds ~200‑400 ms latency (depends on RPC).
Amount granularity Micro‑USDC (6 decimals) → minimum price 0.001 USDC. Keeps the barrier low for callers while still covering compute cost. If gas spikes on Base, the net profit can become negative; you must monitor gas price.
Front‑end platform Cloudflare Workers (edge). Zero‑maintenance, automatic TLS, low cold‑start latency. Workers have a 50 ms CPU limit; heavy model inference must be off‑loaded.
Executor Tiny VPS running a Python script with a distilled sentiment model (≈5 MB). Cheap, always‑on, GPU not needed for this workload. If you need a larger model you’ll need a more expensive instance or GPU.

4. The actual workload: a sentiment‑analysis micro‑service

I picked a straightforward NLP task because it’s easy to quantify cost vs. value:

  • Input: a JSON object { "text": "string" }.
  • Output: { "label": "positive|negative|neutral", "score": float }.
  • Model: a DistilBERT‑base distilled to ~5 MB, running on CPU with transformers + torch. Inference ≈ 12 ms on a modest vCPU.

The executor endpoint (/run) is a simple Flask app:


python
# executor/app.py
from flask import Flask, request, jsonify
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch

app = Flask(__name__)

MODEL_NAME = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME)
model.eval()

LABEL_MAP = {0: "NEGATIVE", 1: "POSITIVE"}

@app.route("/
Enter fullscreen mode Exit fullscreen mode

Top comments (0)