This guide provides an in-depth look at the EVM plugin’s architecture, components, and implementation details.
Architecture Overview
The EVM plugin follows a modular architecture with clear separation of concerns:
┌─────────────────┐ ┌──────────────┐ ┌──────────────┐
│ Actions │────▶│ Service │────▶│ Blockchain │
│ (User Intent) │ │ (EVMService)│ │ (Viem) │
└─────────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌──────────────┐
│ Templates │ │ Providers │
│ (AI Prompts) │ │ (Data Supply)│
└─────────────────┘ └──────────────┘
Core Components
EVMService
The central service that manages blockchain connections and wallet data:
export class EVMService extends Service {
static serviceType = 'evm-service';
private walletProvider: WalletProvider;
private intervalId: NodeJS.Timeout | null = null;
async initialize(runtime: IAgentRuntime): Promise<void> {
// Initialize wallet provider with chain configuration
this.walletProvider = await initWalletProvider(runtime);
// Set up periodic balance refresh
this.intervalId = setInterval(
() => this.refreshWalletData(),
60000 // 1 minute
);
}
async refreshWalletData(): Promise<void> {
await this.walletProvider.getChainConfigs();
// Update cached balance data
}
}
Actions
Transfer Action
Handles native and ERC20 token transfers:
export const transferAction: Action = {
name: 'EVM_TRANSFER',
description: 'Transfer tokens on EVM chains',
validate: async (runtime: IAgentRuntime) => {
const privateKey = runtime.getSetting('EVM_PRIVATE_KEY');
return !!privateKey || runtime.getSetting('WALLET_PUBLIC_KEY');
},
handler: async (runtime, message, state, options, callback) => {
// 1. Extract parameters using AI
const params = await extractTransferParams(runtime, message, state);
// 2. Validate inputs
if (!isAddress(params.toAddress)) {
throw new Error('Invalid recipient address');
}
// 3. Execute transfer
const result = await executeTransfer(params);
// 4. Return response
callback?.({
text: `Transferred ${params.amount} ${params.token} to ${params.toAddress}`,
content: { hash: result.hash }
});
}
};
Swap Action
Integrates with multiple DEX aggregators:
export const swapAction: Action = {
name: 'EVM_SWAP',
description: 'Swap tokens on the same chain',
handler: async (runtime, message, state, options, callback) => {
// 1. Extract swap parameters
const params = await extractSwapParams(runtime, message, state);
// 2. Get quotes from aggregators
const quotes = await Promise.all([
getLiFiQuote(params),
getBebopQuote(params)
]);
// 3. Select best route
const bestQuote = selectBestQuote(quotes);
// 4. Execute swap
const result = await executeSwap(bestQuote);
callback?.({
text: `Swapped ${params.fromAmount} ${params.fromToken} for ${result.toAmount} ${params.toToken}`,
content: result
});
}
};
Bridge Action
Cross-chain token transfers using LiFi:
export const bridgeAction: Action = {
name: 'EVM_BRIDGE',
description: 'Bridge tokens across chains',
handler: async (runtime, message, state, options, callback) => {
const params = await extractBridgeParams(runtime, message, state);
// Get bridge route
const route = await lifi.getRoutes({
fromChainId: params.fromChain,
toChainId: params.toChain,
fromTokenAddress: params.fromToken,
toTokenAddress: params.toToken,
fromAmount: params.amount
});
// Execute bridge transaction
const result = await lifi.executeRoute(route.routes[0]);
callback?.({
text: `Bridging ${params.amount} from ${params.fromChain} to ${params.toChain}`,
content: { hash: result.hash, route: route.routes[0] }
});
}
};
Providers
Wallet Provider
Supplies wallet balance information across all chains:
export const walletProvider: Provider = {
name: 'evmWalletProvider',
get: async (runtime: IAgentRuntime) => {
const service = runtime.getService<EVMService>('evm-service');
const data = await service.getCachedData();
if (!data?.walletInfo) return null;
// Format balance information
const balances = data.walletInfo.chains
.map(chain => `${chain.name}: ${chain.nativeBalance} ${chain.symbol}`)
.join('\n');
return `Wallet balances:\n${balances}\n\nTotal value: $${data.walletInfo.totalValueUsd}`;
}
};
Token Balance Provider
Dynamic provider for checking specific token balances:
export const tokenBalanceProvider: Provider = {
name: 'evmTokenBalance',
get: async (runtime: IAgentRuntime, message: Memory) => {
const tokenAddress = extractTokenAddress(message);
const chain = extractChain(message);
const balance = await getTokenBalance(
runtime,
tokenAddress,
chain
);
return `Token balance: ${balance}`;
}
};
Templates
AI prompt templates for parameter extraction:
export const transferTemplate = `Given the recent messages and wallet information:
{{recentMessages}}
{{walletInfo}}
Extract the transfer details:
- Amount to transfer (number only)
- Recipient address or ENS name
- Token symbol (or 'native' for ETH/BNB/etc)
- Chain name
Respond with:
<response>
<amount>string | null</amount>
<toAddress>string | null</toAddress>
<token>string | null</token>
<chain>string | null</chain>
</response>`;
Chain Configuration
The plugin supports dynamic chain configuration:
interface ChainConfig {
chainId: number;
name: string;
chain: Chain;
rpcUrl: string;
nativeCurrency: {
symbol: string;
decimals: number;
};
walletClient?: WalletClient;
publicClient?: PublicClient;
}
// Chains are configured based on environment variables
const configureChains = (runtime: IAgentRuntime): ChainConfig[] => {
const chains: ChainConfig[] = [];
// Check for custom RPC endpoints
Object.entries(viemChains).forEach(([name, chain]) => {
const customRpc = runtime.getSetting(`ETHEREUM_PROVIDER_${name.toUpperCase()}`);
chains.push({
chainId: chain.id,
name: chain.name,
chain,
rpcUrl: customRpc || chain.rpcUrls.default.http[0],
nativeCurrency: chain.nativeCurrency
});
});
return chains;
};
Token Resolution
The plugin automatically resolves token symbols to addresses:
async function resolveTokenAddress(
symbol: string,
chainId: number
): Promise<Address> {
// Check common tokens first
const commonTokens = {
'USDC': {
1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
// ... other chains
},
'USDT': {
1: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
// ... other chains
}
};
if (commonTokens[symbol]?.[chainId]) {
return commonTokens[symbol][chainId];
}
// Fallback to LiFi token list
const tokens = await lifi.getTokens({ chainId });
const token = tokens.find(t =>
t.symbol.toLowerCase() === symbol.toLowerCase()
);
if (!token) {
throw new Error(`Token ${symbol} not found on chain ${chainId}`);
}
return token.address;
}
Governance Implementation
The plugin includes comprehensive DAO governance support:
// Propose Action
export const proposeAction: Action = {
name: 'EVM_GOV_PROPOSE',
description: 'Create a governance proposal',
handler: async (runtime, message, state, options, callback) => {
const params = await extractProposalParams(runtime, message, state);
const governorContract = getGovernorContract(params.chain);
const tx = await governorContract.propose(
params.targets,
params.values,
params.calldatas,
params.description
);
callback?.({
text: `Created proposal: ${params.description}`,
content: { hash: tx.hash }
});
}
};
// Vote Action
export const voteAction: Action = {
name: 'EVM_GOV_VOTE',
description: 'Vote on a governance proposal',
handler: async (runtime, message, state, options, callback) => {
const params = await extractVoteParams(runtime, message, state);
const voteValue = {
'for': 1,
'against': 0,
'abstain': 2
}[params.support.toLowerCase()];
const tx = await governorContract.castVote(
params.proposalId,
voteValue
);
callback?.({
text: `Voted ${params.support} on proposal ${params.proposalId}`,
content: { hash: tx.hash }
});
}
};
Error Handling
Comprehensive error handling for common scenarios:
export async function handleTransactionError(
error: any,
context: string
): Promise<void> {
if (error.code === 'INSUFFICIENT_FUNDS') {
throw new Error(`Insufficient funds for ${context}`);
}
if (error.code === 'NONCE_TOO_LOW') {
// Handle nonce issues
await resetNonce();
throw new Error('Transaction nonce issue, please retry');
}
if (error.message?.includes('gas required exceeds allowance')) {
throw new Error(`Gas estimation failed for ${context}`);
}
// Log unknown errors
logger.error(`Unknown error in ${context}:`, error);
throw new Error(`Transaction failed: ${error.message}`);
}
Testing
The plugin includes comprehensive test coverage:
describe('EVM Transfer Action', () => {
it('should transfer native tokens', async () => {
const runtime = await createTestRuntime();
const message = createMessage('Send 0.1 ETH to 0x123...');
const result = await transferAction.handler(
runtime,
message,
state,
{},
callback
);
expect(result).toBe(true);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('Transferred 0.1 ETH')
})
);
});
});
Best Practices
- Always validate addresses before executing transactions
- Use gas buffers (typically 20%) for reliable execution
- Implement retry logic for network failures
- Cache frequently accessed data to reduce RPC calls
- Use simulation before executing expensive operations
- Monitor gas prices and adjust limits accordingly
- Handle slippage appropriately for swaps
- Validate token approvals before transfers
Troubleshooting
Common issues and solutions:
- “Insufficient funds”: Check wallet balance includes gas costs
- “Invalid address”: Ensure address is checksummed correctly
- “Gas estimation failed”: Try with a fixed gas limit
- “Nonce too low”: Reset nonce or wait for pending transactions
- “Network error”: Check RPC endpoint availability
Responses are generated using AI and may contain mistakes.