Skip to content

ElizaOS EVM Plugin: How to Send USDC Safely (2026)

Ask the ElizaOS EVM plugin for 25 USDC and it signs a 25 ETH transfer. Here's the test, the docs gap, and a guarded SEND_USDC action with caps.

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 this guide is: an operator test of the stablecoin transfer path in @elizaos/plugin-evm 1.0.13 (npm latest) and 2.0.0-alpha.8 (npm alpha), plus @elizaos/plugin-solana 1.2.6, checked against the published bundles and live Base and Ethereum reads on 8 October 2026. No real funds moved; every signed transaction went to a local mock RPC.

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.

PackageVersion testednpm tagPublished (UTC)30-day downloads
@elizaos/plugin-evm1.0.13latest8 Sep 2025769
@elizaos/plugin-evm2.0.0-alpha.8alpha6 Apr 2026included above
@elizaos/plugin-solana1.2.6latest26 Oct 20251,045
@elizaos/coreframework runtimelatestn/a41,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 extractedSigned tx: toSigned tx: valueCalldataGas limit
amount 25, token USDCRecipient wallet25 ETH (25,000,000,000,000,000,000 wei)empty21000
amount 0.5, token = Base USDC contract addressRecipient wallet0.5 ETHempty21000
amount 100, recipient vitalik.eth (the docs example)n/an/an/aRejected: Address "vitalik.eth" is invalid
Correct USDC transfer for comparisonUSDC contract 0x833589fC...0transfer(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.

⚠️
Warning: if the agent wallet holds enough ETH, a USDC request becomes a real ETH payment. A wallet with 1 ETH asked to "send 0.5 USDC" sends 0.5 ETH and reports success. With less ETH than the amount, the RPC rejects it for insufficient funds and nothing moves. Neither outcome is a USDC transfer.

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.

SourceWhat it says about transfersMatches 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 guideShows a resolveTokenAddress helper with USDC addresses for chain IDs 1 and 8453No (helper isn't in the bundle)
Docs examplesAgent 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.0Has execute_erc20 with real transfer calldataDifferent 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 inputResultWhat it proves
25 USDC to allowlisted addressSigned: to USDC contract, value 0, transfer(recipient, 25000000)Correct asset and base units
2.01 USDC to allowlisted addressSigned: 2010000 base units exactlyNo float rounding
300 USDCRefused: per-transfer capCap holds above 250
5 USDC to an unknown addressRefused: not on allowlistModel can't pick payees
5 USDC to vitalik.ethRefused: must be a 0x addressNo ENS guessing
1.0000001 USDCRefused: more than 6 decimalsNo 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

AttributeEVM_TRANSFER_TOKENS (npm 1.0.13)Guarded SEND_USDC
Asset sentAlways the chain's native coinUSDC only, address pinned per chain
Amount conversionparseEther (18 decimals)parseUnits at 6 decimals from a string
Recipient checkAny address string the model returnsChecksummed allowlist in code
LimitsNonePer-transfer and daily caps
Pre-flightNonesimulateContract, then receipt status check
Success messageGeneric "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.

💡
Stablecoin Insider's take: the ElizaOS EVM plugin is a good agent wallet scaffold and a poor stablecoin payment tool. Its transfer action does what the npm README says, native transfers only, while the docs promise USDC, and the gap converts a stablecoin request into an ETH payment. That isn't a subtle edge case; it's the headline use case. Until a stable release ships an ERC-20 path with real decimals, remove the built-in transfer, keep swap and bridge, and route every USDC payment through a narrow action with an allowlist and caps.

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.

Send Turnkey Agentic USDC Payments
How to Send USDC with Solana Agent Kit (2026)
Stablecoin Insider's tested Solana Agent Kit USDC setup, the float-math bug, Token-2022 limits, and a guarded PAY_USDC action.

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.

Latest