Table of Contents
Ask an ElizaOS agent to send 25 USDC on Base, and the EVM plugin on npm will try to send 25 ETH. Stablecoin Insider signed that transaction in a sandbox and decoded it: right recipient, wrong asset, no token contract anywhere in it.
This guide covers what the ElizaOS EVM plugin actually does with a stablecoin request, why the docs and the code disagree, and how to send USDC safely anyway. You'll get the test results, a drop-in guarded SEND_USDC action with an allowlist and caps, a Base Sepolia test plan, and a quick check of the Solana plugin's token path.
Key Takeaways
- The npm EVM_TRANSFER_TOKENS action ignores the token field and sends native ETH.
- A 25 USDC request signed as a 25 ETH transfer with empty calldata.
- The docs promise ERC-20 transfers; the published 1.0.13 and 2.0.0-alpha.8 code don't.
- Swap and bridge read token decimals on-chain, so they handle USDC amounts correctly.
- Ship a guarded SEND_USDC action: allowlist, caps, parseUnits with 6 decimals.
What the ElizaOS EVM plugin is, and which version npm installs
ElizaOS is an open-source TypeScript framework for building autonomous agents, maintained in the elizaOS/eliza repository. It takes its name from ELIZA, Joseph Weizenbaum's 1960s MIT chatbot. Agents get on-chain abilities through plugins, and the EVM plugin is the one that holds a private key and talks to Ethereum, Base, Arbitrum and other EVM chains.
The plugin ships three money-moving actions: transfer, swap, and bridge. Each one asks a small model to pull parameters out of the chat as XML, then executes. If your character configures no chains, the wallet provider falls back to Ethereum mainnet and Base, per the published 1.0.13 source map.
| Package | Version tested | npm tag | Published (UTC) | 30-day downloads |
|---|---|---|---|---|
| @elizaos/plugin-evm | 1.0.13 | latest | 8 Sep 2025 | 769 |
| @elizaos/plugin-evm | 2.0.0-alpha.8 | alpha | 6 Apr 2026 | included above |
| @elizaos/plugin-solana | 1.2.6 | latest | 26 Oct 2025 | 1,045 |
| @elizaos/core | framework runtime | latest | n/a | 41,356 |
Source: npm registry metadata and the npm downloads API for 5 September to 4 October 2026.
Here's why the numbers matter. The core runtime pulled 41,356 downloads in that window, but the EVM plugin pulled 769, per the npm downloads API. Most ElizaOS agents never touch an EVM wallet. The ones that do are running code that hasn't had a stable release since September 2025.
The problem: EVM_TRANSFER_TOKENS sends ETH when you ask for USDC
The transfer prompt asks the model for five fields: chain, amount, recipient, token, and calldata. The token field is described as "token symbol or address (if not a native token transfer)". So far, so good.
Then the executor ignores it. In the published 1.0.13 transfer action, the only call that moves value is this one:
// @elizaos/plugin-evm 1.0.13, src/actions/transfer.ts (from the npm source map)
const hash = await walletClient.sendTransaction({
account: walletClient.account,
to: params.toAddress,
value: parseEther(params.amount),
data: params.data as Hex,
chain: walletClient.chain,
});
There's no branch on params.token, no ERC-20 ABI, and no lookup of the USDC contract. viem's parseEther converts the amount to wei with 18 decimals, and the transaction goes straight to the recipient as a native transfer. Stablecoin Insider searched the full 1.0.13 bundle for the ERC-20 transfer selector a9059cbb and the Base USDC address: zero hits for either.
The alpha line doesn't fix it. The 2.0.0-alpha.8 bundle renames the action to TRANSFER and adds typed errors and a parameter parser, but its transfer method still calls sendTransaction with value set to parseEther(params.amount).
Stablecoin Insider's test: what the plugin actually signs
Stablecoin Insider installed @elizaos/plugin-evm 1.0.13 from npm, built the plugin's own WalletProvider with a throwaway key on Base (chain ID 8453), and pointed it at a local JSON-RPC mock. The plugin's TransferAction then signed real EIP-1559 transactions, which were captured at eth_sendRawTransaction and decoded with viem. Nothing reached a live network.
| Input the model extracted | Signed tx: to | Signed tx: value | Calldata | Gas limit |
|---|---|---|---|---|
| amount 25, token USDC | Recipient wallet | 25 ETH (25,000,000,000,000,000,000 wei) | empty | 21000 |
| amount 0.5, token = Base USDC contract address | Recipient wallet | 0.5 ETH | empty | 21000 |
| amount 100, recipient vitalik.eth (the docs example) | n/a | n/a | n/a | Rejected: Address "vitalik.eth" is invalid |
| Correct USDC transfer for comparison | USDC contract 0x833589fC... | 0 | transfer(recipient, 25000000) | set by estimate |
Source: Stablecoin Insider test of @elizaos/plugin-evm 1.0.13 on 8 October 2026; USDC contract and 6 decimals per Circle USDC contract addresses.
A gas limit of 21000 is the giveaway. That's the fixed cost of a plain ETH transfer, and a USDC transfer always costs more because it runs the token contract's code, as defined in the ERC-20 standard.
The success message makes it worse. The callback text reads "Successfully transferred 25 tokens", never naming the asset, so a chat log won't tell you ETH left instead of USDC. The only reliable check is the transaction itself on Basescan, where a real USDC payment shows up as a Transfer event on the token contract.
Docs vs code: where the ElizaOS EVM plugin story splits
The confusion isn't your fault. Three official sources describe three different transfer actions.
| Source | What it says about transfers | Matches npm 1.0.13? |
|---|---|---|
| Docs overview | "Transfer native tokens and ERC20 tokens"; example prompt "Transfer 100 USDC to vitalik.eth on Base" | No |
| Docs developer guide | Shows a resolveTokenAddress helper with USDC addresses for chain IDs 1 and 8453 | No (helper isn't in the bundle) |
| Docs examples | Agent replies "Transferring 100 USDC to vitalik.eth on Base network" | No (ENS names are rejected) |
| npm README | "Native token transfers"; "TransferAction: Native token transfers" | Yes |
| Rust crate elizaos-plugin-evm 2.0.0 | Has execute_erc20 with real transfer calldata | Different package |
Source: pages linked in each row, read 8 October 2026; bundle checked at unpkg.
The README is the honest one. It says native transfers, and that's what ships.
The Rust crate is a separate trap. Its execute_erc20 builds correct transfer calldata, but it parses the amount with DEFAULT_DECIMALS, which constants.rs sets to 18. USDC uses 6, so 100 USDC would be encoded as 100 x 10^18 base units, a trillion times too large. On any normal wallet that reverts. Pass base units yourself if you use the Rust path.
How to send USDC with the ElizaOS EVM plugin safely
You don't need to fork the plugin. Keep its wallet provider, swap, and bridge, drop the built-in transfer, and register one narrow action that can only move USDC to addresses you approve.
Step 1: Install the plugin and set the wallet
Add the plugin with the CLI command from the ElizaOS EVM plugin docs, then set the key and an RPC for each chain. The plugin reads ETHEREUM_PROVIDER_<CHAIN> first and EVM_PROVIDER_<CHAIN> second.
elizaos plugins add evm
# .env
EVM_PRIVATE_KEY=0x... # a dedicated agent key, never a treasury key
ETHEREUM_PROVIDER_BASE=https://your-base-rpc
ETHEREUM_PROVIDER_BASESEPOLIA=https://sepolia.base.org
Name the chains explicitly in the character's settings so you don't inherit the mainnet plus Base default. Use viem's chain keys, such as base and baseSepolia. Public endpoints are listed in Base's connection guide, but production agents should use a dedicated RPC.
// character.ts
settings: {
chains: { evm: ['baseSepolia'] }, // switch to ['base'] only after Step 5 passes
},
Key custody is the real decision here. A raw EVM_PRIVATE_KEY is fine on a testnet. On mainnet, put a policy-enforcing signer behind it; Stablecoin Insider compares the options in how to set up an AI agent wallet, with deeper walkthroughs for Turnkey, Privy, and Coinbase agentic wallets.
Step 2: Remove the built-in transfer action
A plugin is a plain object, so you can register a copy without the action you don't trust. In 1.0.13 the action's name is EVM_TRANSFER_TOKENS; in the 2.0 alphas it's TRANSFER.
import evmPlugin from '@elizaos/plugin-evm';
const UNSAFE = new Set(['EVM_TRANSFER_TOKENS', 'TRANSFER']);
export const evmNoTransfer = {
...evmPlugin,
name: 'evm-no-transfer',
actions: (evmPlugin.actions ?? []).filter((a) => !UNSAFE.has(a.name)),
};
Do this even if you never plan to send ETH. The action's similes include EVM_SEND_TOKENS and EVM_TOKEN_TRANSFER, so a model can reach it from almost any "send" phrasing.
Step 3: Add a guarded SEND_USDC action
The core of the fix is a function that only knows one token per chain, converts amounts with parseUnits at 6 decimals from a string (never a float), and runs simulateContract before signing. USDC addresses come from Circle's official list.
import { getAddress, isAddress, parseAbi, parseUnits } from 'viem';
const USDC = {
base: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
baseSepolia: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
} as const;
const ALLOW = new Set([/* approved payee addresses */].map((a) => getAddress(a)));
const MAX_PER_TX = parseUnits('250', 6);
const MAX_PER_DAY = parseUnits('1000', 6);
const ERC20 = parseAbi(['function transfer(address to, uint256 amount) returns (bool)']);
export async function sendUsdc({ chain, to, amount }, wp, spentToday: bigint) {
const token = USDC[chain];
if (!token) throw new Error(`USDC not configured on ${chain}`);
if (!isAddress(to)) throw new Error('Recipient must be a 0x address');
const recipient = getAddress(to);
if (!ALLOW.has(recipient)) throw new Error('Recipient not on allowlist');
if (!/^\d+(\.\d{1,6})?$/.test(String(amount))) throw new Error('Bad amount format');
const units = parseUnits(String(amount), 6);
if (units <= 0n || units > MAX_PER_TX) throw new Error('Per-transfer cap exceeded');
if (spentToday + units > MAX_PER_DAY) throw new Error('Daily cap exceeded');
const wallet = wp.getWalletClient(chain);
const pub = wp.getPublicClient(chain);
const { request } = await pub.simulateContract({
account: wallet.account, address: token, abi: ERC20,
functionName: 'transfer', args: [recipient, units],
});
const hash = await wallet.writeContract(request);
const receipt = await pub.waitForTransactionReceipt({ hash });
if (receipt.status !== 'success') throw new Error(`Reverted: ${hash}`);
return { hash, units };
}
Then wrap it in an ElizaOS action. Keep the model's job small: extract chain, recipient, and amount as strings, and let code decide everything else. Persist spentToday in your database or the runtime cache, not in memory, so a restart doesn't reset the daily cap.
import { initWalletProvider } from '@elizaos/plugin-evm';
export const sendUsdcAction = {
name: 'SEND_USDC',
similes: ['PAY_USDC', 'SEND_STABLECOIN'],
description: 'Send USDC on Base to an approved address. Never sends ETH.',
validate: async (runtime) => !!runtime.getSetting('EVM_PRIVATE_KEY'),
handler: async (runtime, message, state, _opts, callback) => {
const p = await extractUsdcParams(runtime, message, state); // your XML template
const wp = await initWalletProvider(runtime);
const spent = await loadSpentToday(runtime);
const { hash, units } = await sendUsdc(p, wp, spent);
await saveSpentToday(runtime, spent + units);
callback?.({ text: `Sent ${p.amount} USDC on ${p.chain}. Tx: ${hash}` });
return true;
},
examples: [],
};
// character plugins: [evmNoTransfer, { name: 'usdc-pay', actions: [sendUsdcAction] }]
Step 4: Fund a Base Sepolia wallet and run the test cases
Base Sepolia USDC is 0x036CbD53842c5426634e7929541eC2318f3dCF7e, per Circle's address list, and Circle's faucet hands out test USDC. You'll also need a little Sepolia ETH for gas. Then run the cases below before the agent sees a mainnet key.
Stablecoin Insider ran the same guard logic against the mock RPC. Every accepted payment signed a call to the USDC contract with zero ETH value; every bad input was refused before signing.
| Test input | Result | What it proves |
|---|---|---|
| 25 USDC to allowlisted address | Signed: to USDC contract, value 0, transfer(recipient, 25000000) | Correct asset and base units |
| 2.01 USDC to allowlisted address | Signed: 2010000 base units exactly | No float rounding |
| 300 USDC | Refused: per-transfer cap | Cap holds above 250 |
| 5 USDC to an unknown address | Refused: not on allowlist | Model can't pick payees |
| 5 USDC to vitalik.eth | Refused: must be a 0x address | No ENS guessing |
| 1.0000001 USDC | Refused: more than 6 decimals | No silent truncation |
Source: Stablecoin Insider guard test, 8 October 2026, using viem simulateContract and parseUnits.
Add your own cases for the daily cap and for a retried tool call. The fuller checklist is in how to test an AI agent USDC payment before going live, and how to set spend limits for an AI agent USDC wallet covers where caps should live when the signer supports policies.
Step 5: Switch to Base mainnet and reconcile
Change the chain list to base, fund the agent with a small working balance, and watch the first payments on Basescan's USDC token page. Base USDC had 4,384,144,858 tokens in supply at block 52,325,403, read by Stablecoin Insider on 8 October 2026, so liquidity isn't the constraint. Your controls are.
Log the hash, amount, payee, and originating message for every send. That's what lets you match on-chain transfers back to agent decisions later, as covered in how to reconcile AI agent USDC spend. Keep a kill switch ready too; how to revoke an AI agent's access to USDC walks through it.
What about the ElizaOS Solana plugin?
The Solana side does handle tokens, but with sharp edges. In @elizaos/plugin-solana 1.2.6, TRANSFER_SOLANA sends an SPL transfer when the model returns a mint address, and plain SOL when tokenAddress is null. The prompt tells the model to use null both for SOL and for anything it can't determine, so a request that names USDC without a mint can fall through to a SOL transfer.
The amount math is the same pattern Stablecoin Insider flagged in Solana Agent Kit: BigInt(Number(amount) * 10 ** decimals). Running every cent value from 0.01 to 1,000.00 (100,000 amounts) through that exact expression, 2,384 or 2.38% produced non-integers such as 2,009,999.9999999998 for 2.01 USDC, which makes BigInt throw a RangeError before signing.
- Decimals fallback: if the mint lookup returns nothing, the code assumes 9 decimals, which is 1,000 times too large for 6-decimal USDC (see plugin source).
- Token-2022: the transfer uses the classic SPL Token program, so Token-2022 stablecoins like PYUSD fail; the program is documented in the Token-2022 repository.
- Recipient accounts: the agent pays to create the recipient's token account if it doesn't exist, which an attacker can turn into a small fee drain.
Swap and bridge get decimals right
Not everything in the EVM plugin is broken. The swap and bridge actions read decimals() from the token contract and convert with parseUnits before quoting through LI.FI or Bebop, per the 1.0.13 source. A code comment in the bridge action even calls this "THE KEY FIX" over a hardcoded 18. The bridge falls back to 18 decimals if the on-chain read fails, so give it a reliable RPC.
That makes a practical split. Let the plugin swap ETH to USDC or bridge USDC between chains with route checks, and let your own action handle the final payment. If you're choosing routes, Stablecoin Insider ranks options in the best DEX aggregators for stablecoin swaps.
Built-in transfer vs guarded SEND_USDC
| Attribute | EVM_TRANSFER_TOKENS (npm 1.0.13) | Guarded SEND_USDC |
|---|---|---|
| Asset sent | Always the chain's native coin | USDC only, address pinned per chain |
| Amount conversion | parseEther (18 decimals) | parseUnits at 6 decimals from a string |
| Recipient check | Any address string the model returns | Checksummed allowlist in code |
| Limits | None | Per-transfer and daily caps |
| Pre-flight | None | simulateContract, then receipt status check |
| Success message | Generic "tokens" | Names USDC, chain, and hash |
Source: plugin-evm 1.0.13 source map and the Stablecoin Insider test above.
Where this setup still breaks, and who it isn't for
- Prompt injection. An allowlist stops new payees, but not a bad amount to a good payee; the caps are your ceiling.
- Raw keys. EVM_PRIVATE_KEY in an env file is one leaked log away from a drained wallet.
- No idempotency. A retried handler can pay twice, so key each payment and check before resending.
- Alpha drift. 2.0.0 alphas change action names and specs, so re-check the filter in Step 2 on every upgrade.
- High-volume payouts. If you're paying hundreds of vendors a day, use a payout API, not a chat agent.
If your agent mostly pays for APIs, the x402 protocol is a cleaner fit than a free-form transfer action, and paying for an x402 API with Coinbase AgentKit shows that pattern end to end.
Running an ElizaOS agent with a funded EVM wallet today? Check which transfer action it has loaded, run the 25 USDC case on Base Sepolia, and read the signed transaction before you trust the chat reply.
Need a signer that enforces spend policy outside the agent? See how Turnkey handles agentic USDC payments.
Mapping the wider agent payments stack? Start with stablecoin payments for AI agents and AI agents for stablecoins in 2026. Funding comes first, so see how to fund an AI agent wallet with USDC.
FAQ
What is the ElizaOS EVM plugin?
It's the official ElizaOS plugin that gives an agent an EVM wallet and actions to transfer, swap, and bridge tokens. It's published on npm as @elizaos/plugin-evm, with 1.0.13 as the current stable release.
Can ElizaOS send USDC?
Not with the built-in EVM transfer action in 1.0.13 or 2.0.0-alpha.8, which sends the native coin instead. Add a custom action that calls the USDC contract's transfer function with 6-decimal amounts.
Why did my ElizaOS agent send ETH instead of USDC?
The transfer action ignores the token field and calls sendTransaction with value set to parseEther(amount). The amount you asked for in USDC is sent as the same number of ETH if the wallet can cover it.
Does the ElizaOS EVM plugin support ENS names?
Not in the transfer action. In Stablecoin Insider's test, the docs example recipient vitalik.eth was rejected as an invalid address, so resolve names yourself and pass a 0x address.
How do you stop an ElizaOS agent from overspending USDC?
Remove the built-in transfer action and route payments through one action with a payee allowlist plus per-transfer and daily caps in code. A policy-enforcing signer adds a second layer outside the agent.
Can the ElizaOS EVM plugin swap ETH for USDC?
Yes. The swap action reads token decimals on-chain and quotes through LI.FI and Bebop, so USDC amounts are converted correctly. It's the transfer path that's broken, not swaps.
Does the ElizaOS Solana plugin send USDC correctly?
Usually, if the model passes the USDC mint address. It uses float math that throws on 2.38% of cent amounts in Stablecoin Insider's test, falls back to SOL when the mint is null, and doesn't support Token-2022 stablecoins.
This content is provided for informational and educational purposes only and does not constitute financial, investment, legal, or tax advice; no material herein should be interpreted as a recommendation, endorsement, or solicitation to buy or sell.