If your agent can pay over x402 or sign USDC transfers, it can also pay the wrong address. Nobody has to break in for that to happen. Someone only has to influence which address ends up in the payment.
This post covers three ways that happens, why the check belongs before the signature, and how to add one with a small open-source package. It also covers the limits of that check.
Disclosure: I build Verdix, the address-risk API that the package below calls.
Three ways an agent ends up paying the wrong address
1. Prompt injection
An LLM decides what to do from text, and some of that text comes from people you don't control: web pages, emails, tool results, other agents. Whoever writes that text can steer the agent.
The best-known public demo is Freysa, an agent released in November 2024 with one rule: never approve a transfer of its prize pool. After 481 failed attempts, message 482 got it to do exactly that. The attacker convinced it that approveTransfer handled incoming payments, then announced a fake deposit. The agent sent its whole balance, 13.19 ETH (about $47,000).
The lesson isn't that this model was weak. A rule written in the prompt is enforced only by the model that can be talked out of it. If the payment path has no check outside the model, a good enough injection can get through.
2. Address poisoning
Addresses are 40 hex characters, and people (and agents) copy them from transaction history. An attacker generates a lookalike address with the same first and last characters as one you've used before, then sends you a tiny or zero-value transfer from it, so it shows up in your history.
On 3 May 2024, a holder sent 1,155 WBTC, about $68M, to a lookalike address that matched the first and last six characters of the intended recipient. The funds were later returned after negotiation, which is rare.
It isn't a one-off. A USENIX Security 2025 paper, Blockchain Address Poisoning, measured two years on Ethereum and BSC: 270M attack attempts targeting 17M victims, and 6,633 incidents with at least $83.8M in losses.
An agent that takes "the address we paid last time" from history or logs is exactly the user this attack was built for. It also doesn't get the feeling that something looks off.
3. Sanctioned addresses
OFAC's SDN list includes crypto addresses. For example, the 14 April 2022 update added an Ethereum address for the Lazarus Group: 0x098B716B8Aaf21512996dC57EB0615e2383E2f96. The 0xB10C/ofac-sanctioned-digital-currency-addresses project extracts these addresses from Treasury's XML every night.
Here nothing has to trick your agent. An address can be perfectly valid, the payment can go through, and you have still paid a party you may be legally barred from paying. Whether that applies to you is a question for your lawyer, but checking the list costs very little.
Why the check goes before the signature
An x402 payment is an EIP-3009 authorization that the facilitator settles on-chain. A USDC transfer is a transaction. Once either is signed and broadcast, you can't take it back. A post-payment alert only tells you what you lost.
So the check has to run in the signing path:
- outside the model, so a prompt can't talk it out of running;
-
on the final recipient, the
payToortothat will actually be signed, not the name the model was given; - failing closed, so an unanswered check blocks the payment instead of waving it through.
Doing it with verdix-guard
verdix-guard (MIT, source) hooks into two places:
-
x402 payments: it registers an
onBeforePaymentCreationhook on the official@x402client, so thepayTois screened before the payment payload is created. -
Any transfer: it wraps a viem account and screens native transfers, ERC-20
transfer/transferFrom/approve, and EIP-3009 / EIP-2612 signatures before signing.
npm install verdix-guard @x402/fetch @x402/evm viem
For x402:
import { ExactEvmScheme } from '@x402/evm';
import { wrapFetchWithPayment, x402Client } from '@x402/fetch';
import { privateKeyToAccount } from 'viem/accounts';
import { createVerdixGuard, guardX402Client } from 'verdix-guard';
const account = privateKeyToAccount(process.env.AGENT_KEY as `0x${string}`);
const client = new x402Client().register('eip155:8453', new ExactEvmScheme(account));
guardX402Client(client, createVerdixGuard({ account }));
const fetchWithPayment = wrapFetchWithPayment(fetch, client);
const response = await fetchWithPayment('https://seller.example/paid-endpoint');
For direct transfers with viem:
import { createVerdixGuard, guardAccount, VerdixGuardBlockedError } from 'verdix-guard';
const guard = createVerdixGuard({ account });
const guardedAccount = guardAccount(account, guard); // use it in your walletClient
try {
await guardedAccount.signTransaction(tx);
} catch (error) {
if (error instanceof VerdixGuardBlockedError) {
console.log(error.decision.reason, error.decision.verdict);
}
}
A blocked x402 request throws an error containing verdix-guard: <reason>. A guarded account throws VerdixGuardBlockedError. In both cases nothing is signed.
There is no API key. Each check is itself an x402 payment: $0.01 in USDC on Base at the default lite tier, paid from a wallet you choose. Answers are cached for 24 hours per address, and spending on checks is capped at dailyBudgetUsd (default $1). USDC payments below minAmountUsd (default $0.10) aren't checked. Amounts the guard can't price in USD, such as ETH or other tokens, always are.
What it decides by default:
| Answer | Default |
|---|---|
danger |
block, always |
caution |
block (change with onCaution: 'allow' or 'ask') |
no_known_risk / safe
|
allow |
| no usable answer | block (failMode: 'allow' to change) |
A real run
To check the published package end to end, I installed verdix-guard@0.1.0 from npm into an empty directory. I wrapped a test wallet with guardAccount, using tier: 'lite', minAmountUsd: 0 and dailyBudgetUsd: 0.01, and asked it to sign a 0.20 USDC transfer to 0x32fb1bedd95bf78ca2c6943ae5aeaeaafc0d97c1. That's an exploit contract on Base, listed in DeFiHackLabs as CloberDEX (2024-12).
- Verdix answered
dangerwith the reasondeployer_flagged. - The guard threw
VerdixGuardBlockedError, and no signature was produced. - The send function was a local stub, called only after a successful signature. It was never called, and the test wallet's nonce on Base stayed at 0.
- The only money that moved was the check itself: 0.01 USDC, tx
0xd3c4a29f…eb54a0(Base block 52220766).
One practical note if you reproduce this: a viem wallet client may call its transport (for example eth_fillTransaction) before signing. To prove that nothing left the machine, sign with the guarded account directly, not through the wallet client.
The limits, honestly
-
safeisn't a guarantee. It means the checks that ran found nothing. A brand-new scam address that is on no list yet and has no on-chain history will pass. Keep confirming large or unusual payments with a human. -
litenever sayssafe. The default tier checks sanctions, scam and phishing lists, address-poisoning lookalikes, burn addresses, phishing tokens and flagged deployers. It skips address age and some contract checks. A clean result isno_known_risk, which says less thansafe. If you needsafe, usetier: 'quick'($0.02). -
Failing closed has a cost. With the default
failMode: 'block', your agent stops paying while Verdix or one of its data sources is unavailable.failMode: 'allow'keeps it running, but those payments go through unchecked. An answer that already saysdangeris blocked in both modes. -
Base only. Payments on other networks pass through unchecked by default (
otherNetworks: 'allow'). -
Not everything is decoded. Raw
sign/signMessage, EIP-7702 authorizations and contract calls that aren't a known transfer or approval pass through unchanged. - State is in memory. The cache and the daily budget reset when your process restarts.
- It doesn't fix prompt injection. It narrows what an injected agent can do with money. Keep your other controls too: allowlists for known payees, per-payment caps in the wallet, and a human approval step above a threshold.
Using MCP, Vercel AI SDK or LangChain?
The same check is also available as an agent tool. Every package below has a lite tool ($0.01) whose clean answer is no_known_risk, never safe:
-
MCP:
verdix-mcp(npm, GitHub), run withnpx -y verdix-mcp. A one-file.mcpbbundle for Claude Desktop is attached to the latest GitHub release. -
Vercel AI SDK:
verdix-ai-sdk(npm, GitHub). It also hasverdixNeedsApproval, aneedsApprovalfor your own transfer tool that asks the user only when Verdix finds a risk. -
LangChain:
langchain-verdix(PyPI, GitHub).
Takeaway
If your agent can sign payments, put a check between the decision and the signature: one the model can't skip and that blocks when it can't get an answer. The package above is one way to do that. Whatever you use, test it with a payment to an address you know is bad, before an attacker does it for you.
Top comments (0)