Most tutorials on AI agents stop at chat interfaces and RAG pipelines. This one doesn't.
This guide walks through building a production-grade AI agent that can read on-chain data, interact with smart contracts, and execute DeFi operations — using LangChain's agent framework, ethers.js, and a set of custom tools you'll write from scratch.
By the end, you'll have an agent that can:
- Query wallet balances and token holdings
- Read state from any smart contract via ABI
- Simulate and execute token swaps on a DEX
- Monitor on-chain events and trigger actions autonomously Stack
- LangChain (TypeScript) — agent framework
- ethers.js v6 — blockchain interaction
- OpenAI GPT-4o — LLM backbone
- Alchemy — RPC provider
- Hardhat — local fork for safe testing
1. Project Setup
mkdir ai-blockchain-agent && cd ai-blockchain-agent
npm init -y
npm install langchain @langchain/openai @langchain/core ethers dotenv
npm install -D typescript ts-node @types/node hardhat
npx tsc --init
.env:
OPENAI_API_KEY=your_openai_key
ALCHEMY_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your_key
PRIVATE_KEY=your_wallet_private_key
2. Initializing the Provider and Signer
// src/provider.ts
import { ethers } from "ethers";
import * as dotenv from "dotenv";
dotenv.config();
export const provider = new ethers.JsonRpcProvider(process.env.ALCHEMY_RPC_URL);
export const signer = new ethers.Wallet(
process.env.PRIVATE_KEY!,
provider
);
3. Building Custom Blockchain Tools
LangChain agents are only as powerful as their tools. Every on-chain operation becomes a tool the agent can call. Each tool needs a name, description (the LLM reads this to decide when to use it), and a func.
Tool 1 — Get ETH Balance
// src/tools/getBalance.ts
import { DynamicTool } from "@langchain/core/tools";
import { provider } from "../provider";
import { ethers } from "ethers";
export const getBalanceTool = new DynamicTool({
name: "get_eth_balance",
description:
"Get the ETH balance of a wallet address. Input should be a valid Ethereum address.",
func: async (address: string) => {
try {
const balance = await provider.getBalance(address);
return `Balance: ${ethers.formatEther(balance)} ETH`;
} catch (err) {
return `Error fetching balance: ${err}`;
}
},
});
Tool 2 — Read ERC-20 Token Balance
// src/tools/getTokenBalance.ts
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { ethers } from "ethers";
import { provider } from "../provider";
const ERC20_ABI = [
"function balanceOf(address owner) view returns (uint256)",
"function decimals() view returns (uint8)",
"function symbol() view returns (string)",
];
export const getTokenBalanceTool = new DynamicStructuredTool({
name: "get_token_balance",
description:
"Get the ERC-20 token balance for a wallet. Provide the token contract address and wallet address.",
schema: z.object({
tokenAddress: z.string().describe("ERC-20 token contract address"),
walletAddress: z.string().describe("Wallet address to check"),
}),
func: async ({ tokenAddress, walletAddress }) => {
try {
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
const [balance, decimals, symbol] = await Promise.all([
contract.balanceOf(walletAddress),
contract.decimals(),
contract.symbol(),
]);
const formatted = ethers.formatUnits(balance, decimals);
return `${formatted}${symbol}`;
} catch (err) {
return `Error: ${err}`;
}
},
});
Tool 3 — Read Arbitrary Contract State
This is where it gets powerful — the agent can query any contract if you give it an ABI.
// src/tools/readContract.ts
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { ethers } from "ethers";
import { provider } from "../provider";
export const readContractTool = new DynamicStructuredTool({
name: "read_contract",
description:
"Call a read-only function on any smart contract. Provide the contract address, ABI as a JSON string, function name, and arguments array.",
schema: z.object({
contractAddress: z.string(),
abi: z.string().describe("JSON string of the contract ABI"),
functionName: z.string(),
args: z.array(z.string()).default([]),
}),
func: async ({ contractAddress, abi, functionName, args }) => {
try {
const parsedAbi = JSON.parse(abi);
const contract = new ethers.Contract(contractAddress, parsedAbi, provider);
const result = await contract[functionName](...args);
return `Result: ${result.toString()}`;
} catch (err) {
return `Error: ${err}`;
}
},
});
Tool 4 — Get Gas Price and Estimate
// src/tools/getGasPrice.ts
import { DynamicTool } from "@langchain/core/tools";
import { provider } from "../provider";
import { ethers } from "ethers";
export const getGasPriceTool = new DynamicTool({
name: "get_gas_price",
description: "Get the current gas price on Ethereum mainnet in Gwei.",
func: async () => {
try {
const feeData = await provider.getFeeData();
const gasPriceGwei = ethers.formatUnits(feeData.gasPrice!, "gwei");
const maxFeeGwei = ethers.formatUnits(feeData.maxFeePerGas!, "gwei");
return `Base gas price: ${parseFloat(gasPriceGwei).toFixed(2)} Gwei | Max fee: ${parseFloat(maxFeeGwei).toFixed(2)} Gwei`;
} catch (err) {
return `Error: ${err}`;
}
},
});
Tool 5 — Simulate a Uniswap V3 Swap (Quote Only)
// src/tools/getSwapQuote.ts
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { ethers } from "ethers";
import { provider } from "../provider";
// Uniswap V3 Quoter contract on mainnet
const QUOTER_ADDRESS = "0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6";
const QUOTER_ABI = [
"function quoteExactInputSingle(address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint160 sqrtPriceLimitX96) external returns (uint256 amountOut)",
];
export const getSwapQuoteTool = new DynamicStructuredTool({
name: "get_swap_quote",
description:
"Get a Uniswap V3 swap quote. Returns how many tokens you'd receive for a given input amount.",
schema: z.object({
tokenIn: z.string().describe("Input token contract address"),
tokenOut: z.string().describe("Output token contract address"),
fee: z.number().describe("Pool fee tier: 500, 3000, or 10000"),
amountIn: z.string().describe("Amount in (human-readable, e.g. '1.5')"),
decimalsIn: z.number().default(18),
decimalsOut: z.number().default(18),
}),
func: async ({ tokenIn, tokenOut, fee, amountIn, decimalsIn, decimalsOut }) => {
try {
const quoter = new ethers.Contract(QUOTER_ADDRESS, QUOTER_ABI, provider);
const amountInWei = ethers.parseUnits(amountIn, decimalsIn);
const amountOut = await quoter.quoteExactInputSingle.staticCall(
tokenIn,
tokenOut,
fee,
amountInWei,
0
);
const formatted = ethers.formatUnits(amountOut, decimalsOut);
return `You would receive approximately ${formatted} tokens for ${amountIn} input tokens.`;
} catch (err) {
return `Error getting quote: ${err}`;
}
},
});
4. Assembling the Agent
Now plug all tools into a LangChain agent with memory and a system prompt that defines its behavior.
// src/agent.ts
import { ChatOpenAI } from "@langchain/openai";
import { AgentExecutor, createOpenAIFunctionsAgent } from "langchain/agents";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { BufferMemory } from "langchain/memory";
import { getBalanceTool } from "./tools/getBalance";
import { getTokenBalanceTool } from "./tools/getTokenBalance";
import { readContractTool } from "./tools/readContract";
import { getGasPriceTool } from "./tools/getGasPrice";
import { getSwapQuoteTool } from "./tools/getSwapQuote";
const tools = [
getBalanceTool,
getTokenBalanceTool,
readContractTool,
getGasPriceTool,
getSwapQuoteTool,
];
const llm = new ChatOpenAI({
modelName: "gpt-4o",
temperature: 0,
openAIApiKey: process.env.OPENAI_API_KEY,
});
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
`You are a DeFi-native AI agent with direct access to the Ethereum blockchain.
You can read wallet balances, token holdings, smart contract state, gas prices, and DEX swap quotes.
Always verify addresses before acting. When providing numbers, format them clearly with appropriate units.
Never guess on-chain data — always use your tools to fetch it.
If a user asks you to perform a transaction, confirm all parameters before proceeding.`,
],
new MessagesPlaceholder("chat_history"),
["human", "{input}"],
new MessagesPlaceholder("agent_scratchpad"),
]);
const memory = new BufferMemory({
memoryKey: "chat_history",
returnMessages: true,
});
export async function createBlockchainAgent() {
const agent = await createOpenAIFunctionsAgent({ llm, tools, prompt });
return new AgentExecutor({
agent,
tools,
memory,
verbose: true,
maxIterations: 10,
});
}
5. Running the Agent
// src/index.ts
import { createBlockchainAgent } from "./agent";
import * as dotenv from "dotenv";
dotenv.config();
async function main() {
const agent = await createBlockchainAgent();
const queries = [
"What is the ETH balance of 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?",
"What's the current gas price on Ethereum?",
"How much USDC would I get if I swap 1 ETH on Uniswap V3? USDC address is 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, WETH is 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, use the 500 fee tier.",
];
for (const query of queries) {
console.log(`\n🧠 Query: ${query}`);
const result = await agent.invoke({ input: query });
console.log(`✅ Response: ${result.output}`);
}
}
main().catch(console.error);
Run it:
npx ts-node src/index.ts
6. Testing Safely With a Hardhat Mainnet Fork
Never test write operations on mainnet directly. Fork it locally:
npm install -D hardhat @nomicfoundation/hardhat-toolbox
npx hardhat node --fork https://eth-mainnet.g.alchemy.com/v2/your_key
Then in .env, point your ALCHEMY_RPC_URL to http://127.0.0.1:8545 for local testing. Your agent will interact with a real state snapshot without risking real funds.
7. Where to Take This Further
This agent is a foundation. Production extensions include:
- Event listeners — watch for on-chain events (liquidations, large swaps) and trigger agent actions autonomously
- Multi-agent architecture — one agent monitors, another executes, a third validates
- Write operations — extend tools to sign and broadcast transactions (use Hardhat fork to test first)
- Cross-chain support — swap the provider per chain; the agent architecture stays identical
- On-chain memory — store agent state in a smart contract instead of in-memory buffer
Closing Thoughts
The gap between AI agents and blockchain infrastructure is closing fast. LangChain's tool abstraction makes it straightforward to expose any on-chain operation as a callable function — the agent handles reasoning, sequencing, and decision-making on top.
The architecture above is minimal by design. The real complexity in production systems isn't wiring the tools — it's designing safe execution boundaries, handling RPC failures gracefully, and making sure the agent can't be prompted into signing something it shouldn't.
Build the guardrails before you build the features.
I'm Fahad Arif — Blockchain Developer, Smart Contract Auditor, and DeFi Consultant. I build and secure production Web3 systems across EVM chains and Solana. More at my website.