How I Built an Autonomous AI Agent That Earns USDC While I Sleep
I wanted to see whether a modest, fully‑on‑chain agent could generate a trickle of USDC without constant supervision. The goal wasn’t to build a profit‑making machine; it was to explore the practical limits of chaining LLMs, deterministic tasks, and micropayments on a layer‑2 network. Below is a walk‑through of the system I ended up with, the code that makes it tick, and the trade‑offs I ran into along the way.
Architecture Overview
The agent follows a simple loop:
- Observation – pull market data from a public API.
- Decision – ask a language model to suggest a tiny arbitrage action (e.g., “buy token A on exchange X, sell on Y if spread > 0.5 %”).
- Execution – if the model’s suggestion passes safety checks, submit the trade via a DEX aggregator API.
- Settlement – receive any USDC earned and log the result.
All steps run inside a lightweight Docker container that wakes up every five minutes via a cron‑like scheduler. The only on‑chain interaction is the receipt of USDC; the agent never holds private keys for a wallet that can spend funds—it only receives payments to a predetermined address.
Language Model Component
I used a locally hosted Llama‑2‑7B model quantized to 4‑bit via llama.cpp. The model receives a short prompt that includes the current price spread and a request for a binary decision (“YES” if the spread justifies a trade, otherwise “NO”).
from llama_cpp import Llama
llama = Llama(
model_path="models/llama-2-7b-chat.ggmlv3.q4_K_M.bin",
n_ctx=512,
n_threads=4,
)
def should_trade(spread: float) -> bool:
prompt = (
f"The current price spread between Exchange A and Exchange B is {spread:.3%}. "
"If you think executing a small arbitrage trade (buy low, sell high) would be profitable after gas, "
"answer with exactly 'YES'. Otherwise answer with exactly 'NO'."
)
output = llama(prompt, max_tokens=4, stop=["\n"])
return output["choices"][0]["text"].strip().upper() == "YES"
The model is deliberately kept tiny: inference takes ~120 ms on a modest CPU core, which keeps the loop cheap and predictable. Larger models would improve nuance but add latency and cost that outweigh the marginal gain for this use case.
Task Engine: Price Arbitrage
The arbitrage logic is intentionally simplistic. I fetch the mid‑price of USDC/USDT on two exchanges (CoinGecko for simplicity) and compute the spread. If the spread exceeds a threshold (0.4 %), the agent attempts a swap of 10 USDC from the cheaper to the more expensive exchange via the 1inch API.
import requests
COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price"
ONEINCH_URL = "https://api.1inch.io/v5.0/1/swap"
def get_prices():
resp = requests.get(
COINGECKO_URL,
params={"ids": "usd-coin,tether", "vs_currencies": "usd"},
timeout=5,
)
data = resp.json()
return data["usd-coin"]["usd"], data["tether"]["usd"]
def execute_swap(from_token, to_token, amount):
# 1inch expects token addresses; on Base we use the wrapped versions
params = {
"src": from_token,
"dst": to_token,
"amount": str(int(amount * 1e6)), # USDC has 6 decimals
"slippage": "1",
"disableEstimate": "false",
}
headers = {"Authorization": f"Bearer {ONEINCH_API_KEY}"}
resp = requests.get(ONEINCH_URL, params=params, headers=headers, timeout=10)
resp.raise_for_status()
return resp.json()
The trade size is kept low (10 USDC) to limit exposure. If the transaction fails (e.g., due to slippage or insufficient liquidity), the agent logs the error and waits for the next cycle.
Payment Handler: x402 on Base
Earnings are collected via the x402 micropayment standard. Each successful swap triggers a call to a simple paymaster contract deployed on Base that mints USDC to the agent’s address when the caller pays a small fee in ETH. The agent itself never initiates the payment; it merely signs a message authorizing the paymaster to release the owed USDC after the trade settles.
from eth_account import Account
from web3 import Web3
w3 = Web3(Web3.HTTPProvider("https://base.mainnet.rpc.acme.com"))
PAYMASTER = w3.to_checksum_address("0xPaymasterAddress")
AGENT = w3.to_checksum_address("0xAgentAddress")
def claim_earnings(tx_hash):
# Build a simple EIP‑712 typed data structure for the paymaster
domain = {
"name": "USDC Paymaster",
"version": "1",
"chainId": w3.eth.chain_id,
"verifyingContract": PAYMASTER,
}
types = {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"},
],
"Claim": [
{"name": "agent", "type": "address"},
{"name": "txHash", "type": "bytes32"},
],
}
message = {"agent": AGENT, "txHash": w3.to_bytes(hexstr=tx_hash)}
signed = Account.sign_typed_data(w3.eth.account.from_key(PRIVATE_KEY).key, domain, types, message)
# Send the signature to the paymaster (off‑chain relayer or direct call)
tx = PAYMASTER_contract.functions.claim(signed.v, signed.r, signed.s).build_transaction(
{"from": AGENT, "nonce": w3.eth.get_transaction_count(AGENT)}
)
signed_tx = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
w3.eth.send_raw_transaction(signed_tx.rawTransaction)
The paymaster contract is trivial: it checks the signature, verifies that the associated trade succeeded (by looking up the tx hash on‑chain), and then transfers the pre‑agreed USDC amount. This keeps the agent’s on‑chain footprint minimal—only a signature verification and a token transfer.
Putting It All Together: Agent Loop
import time
import logging
logging.basicConfig(level=logging.INFO)
while True:
try:
usdc_price, usdt_price = get_prices()
spread = abs(usdc_price - usdt_price) / usdc_price
logging.info(f"USDC={usdc_price:.6f}, USDT={usdt_price:.6f}, spread={spread:.4%}")
if spread > 0.004 and should_trade(spread):
# Determine direction: buy cheaper, sell dearer
if usdc_price < usdt_price:
tx = execute_swap("USDC", "USDT", 10)
direction = "USDC→USDT"
else:
tx = execute_swap("USDT", "USDC", 10)
direction = "USDT→USDC"
logging.info(f"Executed {direction} swap, tx={tx['txid']}")
claim_earnings(tx["txid"])
else:
logging.info("Spread too small or model advised against trade.")
except Exception as e:
logging.exception(f"Error in agent cycle: {e}")
time.sleep(300) # five‑minute interval
The loop is deliberately defensive: every external call is wrapped in a try/except, and the agent never leaks private keys beyond the signing step (which occurs only in memory).
Top comments (0)