How I Built an Autonomous AI Agent That Earns USDC While I Sleep
Target audience: developers who want to run a self‑sustaining AI service that receives micropayments in USDC on the Base L2.
1. Why an autonomous agent?
When you expose a model or a data‑processing pipeline as a paid API, the most tedious part is the operational loop: monitoring usage, topping up balances, handling failed transactions, and keeping the service online. An autonomous agent can encapsulate all of those chores in a single process that:
- Receives payment‑triggered requests via a smart‑contract escrow.
- Verifies that the payer has deposited enough USDC before executing work.
- Runs the inference or computation, returns the result, and automatically forwards the earned USDC to a wallet you control.
The goal is not to promise “passive income” but to reduce the manual ops overhead to a level where the service can stay alive for weeks without intervention.
2. High‑level architecture
+----------------+ USDC (Base) +-------------------+
| User dApp | <--------------------> | Payment Escrow |
| (frontend) | (ERC‑20 transfer) | (simple solvable)|
+----------------+ +-------------------+
^ |
| JSON‑RPC over HTTPS | Callback (payment confirmed)
| v
+----------------+ +-------------------+
| Agent Service | <----------------> | Wallet & USDC |
| (Python/FastAPI) | signed tx | (Metamask/Ledger)|
+----------------+ +-------------------+
-
Payment Escrow – a minimal ERC‑20 escrow contract deployed on Base. It holds USDC from the caller, emits a
PaymentReceivedevent when the amount ≥ price, and releases funds to the agent’s address on arelease()call. -
Agent Service – a stateless FastAPI process that:
- Listens for
PaymentReceivedevents via WebSocket or polling. - Validates the sender’s signature (to prevent replay).
- Executes the core workload (model inference, data transform, etc.).
- Calls
escrow.release(txId)to pull the earned USDC into its wallet.
- Listens for
- Wallet – an externally owned account (EOA) that holds the agent’s USDC balance. For production you’d replace this with a multisig or a smart‑contract wallet, but an EOA keeps the example simple.
3. Escrow contract (Solidity, ~70 LOC)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract USDCProxyEscrow {
IERC20 public immutable usdc;
address public immutable agent; // receives funds after release
uint256 public price; // price per call, in USDC * 1e6 (6 decimals)
struct Job {
bytes32 id;
address payer;
bool released;
}
mapping(bytes32 => Job) public jobs;
event PaymentReceived(bytes32 indexed id, address payer, uint256 amount);
event Released(bytes32 indexed id, address to, uint256 amount);
constructor(address _usdc, address _agent, uint256 _pricePerCall) {
usdc = IERC20(_usdc);
agent = _agent;
price = _pricePerCall;
}
/// @notice Fund the escrow for a specific job id.
/// The caller must first approve the escrow to spend `amount` USDC.
function deposit(bytes32 jobId, uint256 amount) external {
require(amount >= price, "Insufficient deposit");
require(!jobs[jobId].released, "Already processed");
usdc.transferFrom(msg.sender, address(this), amount);
jobs[jobId] = Job({id: jobId, payer: msg.sender, released: false});
emit PaymentReceived(jobId, msg.sender, amount);
}
/// @notice Agent calls this after completing the work.
function release(bytes32 jobId) external {
Job storage j = jobs[jobId];
require(msg.sender == agent, "Only agent");
require(!j.released, "Already released");
uint256 bal = usdc.balanceOf(address(this));
require(bal >= price, "Escrow underfunded");
usdc.transfer(agent, price);
j.released = true;
emit Released(jobId, agent, price);
}
/// @notice Refund if the job is never completed (optional).
function refund(bytes32 jobId) external {
Job storage j = jobs[jobId];
require(msg.sender == j.payer, "Only payer");
require(!j.released, "Already released");
uint256 bal = usdc.balanceOf(address(this));
usdc.transfer(payer, bal);
j.released = true;
}
}
Trade‑offs
| Aspect | Choice | Reason | Drawback |
|---|---|---|---|
| Escrow logic | Minimal, no upgradeability | Low gas, easy to audit | No ability to change price without redeploying |
| Price storage |
uint256 (6 decimals) |
Matches USDC on Base | Requires caller to know exact decimal scaling |
| Refund | Optional, manual | Prevents funds locking forever | Adds extra transaction cost if used |
4. Agent service (Python 3.11, FastAPI)
Below is a runnable skeleton. Replace MODEL_INFERENCE with your actual workload (e.g., a HuggingFace transformers call, a SQL query, or a scraping routine).
python
# agent.py
import os
import asyncio
import json
from decimal import Decimal
from web3 import Web3
from web3.middleware import geth_poa_middleware
from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel
import uvicorn
# ---------- Configuration ----------
RPC_URL = os.getenv("BASE_RPC", "https://mainnet.base.org") # public RPC, consider a private node for prod
USDC_ADDRESS = Web3.to_checksum_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") # USDC on Base
ESCROW_ADDRESS = Web3.to_checksum_address(os.getenv("ESCROW_ADDR")) # set after deployment
AGENT_PRIVATE_KEY = os.getenv("AGENT_PRIVKEY") # EOA that will receive USDC
PRICE_USDC = Decimal(os.getenv("PRICE_USDC", "0.05")) # price per call, e.g. $0.05
# -----------------------------------
w3 = Web3(Web3.HTTPProvider(RPC_URL))
w3.middleware_onion.inject(geth_poa_middleware, layer=0)
usdc_abi = [
{"constant":True,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"type":"function"},
{"constant":False,"inputs":[{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"type":"function"},
{"constant":False,"inputs":[{"name":"_from","type":"address"},{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"","type":"bool"}],"type":"function"},
{"constant":True,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"type":"function"}
]
usdc = w3.eth.contract(address=USDC_ADDRESS, abi=usdc_abi)
escrow_abi = json.loads("""[
{"inputs":[{"internalType":"address","name":"_usdc","type":"address"},{"internalType":"address","name":"_agent","type":"address"},{"internalType":"uint256","name":"_pricePerCall","type":"uint256"}],"stateMutability":"nonpayable","type":"constructor"},
{"inputs":[{"internalType":"bytes32","name":"jobId","type":"bytes32"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"deposit","outputs":[],"type":"function"},
{"inputs":[{"internalType":"bytes32","name":"jobId","type":"bytes32"}],"name":"release","outputs":[],"type":"function"},
{"inputs":[{"internalType":"bytes32","name":"jobId","type":"bytes32"}],"name":"refund","outputs":[],"type":"function"},
{"anonymous":False,"inputs":[{"indexed":True,"internalType":"bytes32","name":"id","type":"bytes32"},{"indexed":True,"internalType":"address","name":"payer","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"PaymentReceived","type":"event"},
{"anonymous":False,"inputs":[{"indexed":True,"internalType":"bytes32","name":"id","type":"bytes32"},{"indexed":True,"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Released","type":"event"}
]""")
escrow = w3.eth.contract(address=ESCROW_ADDRESS, abi=escrow_abi)
agent_acct = w3.eth.account.from_key(AGENT_PRIVATE_KEY)
app = FastAPI()
class JobReq(BaseModel):
job_id: str # hex string, 32 bytes
payload: dict # whatever your model needs
# ---- Core work placeholder ----
def MODEL_INFERENCE(payload: dict) -> dict:
# Replace with actual inference; this dummy just echoes back.
return {"result": "ok", "
Top comments (0)