I run a helpdesk where autonomous AI agents answer real support tickets and get paid in USDC when a human approves the answer. It worked on four EVM chains. Last week I added Solana.
The transfer code was the easy part. What nearly shipped was four bugs that each looked like nothing, and each would have quietly taken an agent's fee and never paid it out.
None of them crashed. That is the point. Every one of them failed by silently doing nothing, which on a money path is worse than an exception.
The setup
Two address spaces:
-
EVM: 20 bytes, rendered as hex behind an
0x, case-insensitive - Solana: 32 bytes, rendered as base58, case-SENSITIVE
Almost every bug below is that one sentence, discovered the hard way.
Trap 1: lowercasing an identifier that isn't hex
Payouts are keyed to the wallet that submitted the work. That wallet is parsed out of an identifier:
// The original. Fine for years, because everything was EVM.
const id = String(author.id).toLowerCase()
if (!id.startsWith('x402:')) return null
const wallet = id.slice(id.lastIndexOf(':') + 1)
return /^0x[0-9a-f]{40}$/.test(wallet) ? wallet : null
Lowercasing is correct for hex: it canonicalises 0xAbC… and 0xabc… into one string,
so one address is one key. Applied to base58 it is destructive. DRiP2Pn2K6fu… and
drip2pn2k6fu… are not the same account, and the second one is not an account at all.
So a Solana agent's wallet failed the shape test, the function returned null, and the award was dropped as if a human had written the answer. No error. No log. The agent paid its fee, did the work, won, and got nothing.
The fix is to fold only the form where folding means something:
const raw = String(author.id)
if (!raw.toLowerCase().startsWith('x402:')) return null
const wallet = raw.slice(raw.lastIndexOf(':') + 1)
if (/^0x[0-9a-fA-F]{40}$/.test(wallet)) return wallet.toLowerCase() // hex: canonicalise
return isSolanaAddress(wallet) ? wallet : null // base58: never touch
Generalisable rule: before you add a second address space, grep your codebase for toLowerCase() on anything that identifies value. Every hit is a decision you made implicitly when there was only one format.
Trap 2: the same regex, one layer down
The payout sender re-validates the recipient immediately before money leaves. Defence in depth, and a good idea:
if (!/^0x[0-9a-f]{40}$/.test(wallet)) {
await markFailed(id, 'invalid-recipient')
continue
}
Every legitimate Solana winner would have been marked invalid-recipient here, by a check that exists specifically to protect them.
Validation has to follow the row's own chain:
function isValidRecipient(network: string, wallet: string): boolean {
return isSolanaNetwork(network) ? isSolanaAddress(wallet) : /^0x[0-9a-f]{40}$/.test(wallet)
}
The lesson isn't "that regex was wrong". It's that a hardcoded format assumption tends to appear more than once, and the copies don't know about each other. Trap 1 and Trap 2 are the same bug in two files, and fixing one would have left the other.
Trap 3: comparing wei to lamports
The drain refuses to send if the wallet is low on gas, so a payout pauses cleanly instead of failing halfway:
const MIN_GAS_WEI = 30_000_000_000_000n // ~0.00003 ETH
if (await chain.gasWei() < MIN_GAS_WEI) return { floatDry: true }
Both are "the chain's smallest unit", so the types line up perfectly. But:
- 1 ETH = 10^18 wei
- 1 SOL = 10^9 lamports
A wallet holding a healthy 0.05 SOL reports 50_000_000 lamports. Against a floor of
30_000_000_000_000, that is dry by a factor of about 600,000. Every Solana payout would have paused forever, on a wallet with plenty of gas, and the check would have reported it as a funding problem.
bigint gives you no protection here. Both sides are integers, both are "smallest unit", and the comparison is meaningless. The floor has to travel with the chain:
interface PayoutChain {
gasWei(): Promise<bigint>
/** The floor gasWei() must clear, in the SAME unit gasWei() returns. */
minGasUnits?: bigint
}
if (await chain.gasWei() < (chain.minGasUnits ?? MIN_GAS_WEI)) return { floatDry: true }
Generalisable rule: a shared unit name is not a shared unit. If two chains both call it "the smallest unit", that is a naming coincidence, not a conversion.
Trap 4: case-folding an idempotency key
Deposits are claimed by naming the transaction that delivered them. One credit per (network, txHash), so a double-claim is impossible:
const txHash = args.txHash.trim().toLowerCase()
Two failures at once. A Solana signature is base58, so lowercasing produces a string that identifies nothing, and the deposit could never be verified. Worse, it makes the idempotency key lossy: two genuinely different signatures can fold to the same lowercase
string, and the second real deposit would be rejected as "already credited".
Same shape as Trap 1, in the one place where being wrong means crediting money twice or losing it entirely.
The one that was found by testing, not reading
Those four came from reading code with the two address spaces in mind. This one only appeared when I ran the real thing against a real Solana runtime:
throw new Error(`simulation failed: ${JSON.stringify(sim.value.err)}`)
Solana's error objects carry u64 fields as BigInt, and JSON.stringify throws on a BigInt rather than skipping it. So a failed payout recorded the reason as:
Do not know how to serialize a BigInt
The row parked with an error message about serialisation instead of the actual on-chain reason, which is precisely the information an operator needs and cannot reconstruct later.
JSON.stringify(err, (_k, v) => (typeof v === 'bigint' ? v.toString() : v))
How to actually test this
The public devnet faucet rate-limited me immediately, which is common. solana-test-validator
gives you a real RPC on localhost with unlimited airdrops, and it runs the same runtime.
I minted my own 6-decimal token rather than waiting on a faucet for devnet USDC. The mint is a parameter everywhere it appears, so nothing under test changed. The drill:
- fund a wallet
- pay an agent that has never held the token (this is the case that fails without idempotent token-account creation, and it costs rent out of your float)
- read that payment back as a deposit
- assert the guards refuse a token-account recipient, an EVM address, and an overspend
Step 2 is the one people skip. On Solana a recipient needs an Associated Token Account before they can receive an SPL token at all, and creating one costs about 0.00204 SOL of rent, paid by you, unrecoverable. That is a real per-recipient cost that has no EVM
equivalent, and if you don't handle it, paying a brand-new wallet just fails.
What I'd tell myself before starting
-
Grep for
toLowerCaseon anything identifying value. Every hit is an assumption. -
Grep for
0xregexes. They multiply, and the copies don't know about each other. - A shared unit name is not a shared unit. wei and lamports differ by 10^9.
- Serialise errors defensively. The error path is the one you never test.
- Run it against a real runtime before you trust it. Four of these I found by reading.
The fifth only appeared when a real transaction failed for a real reason.
The through-line: adding a second chain is not mostly about transactions. It's about finding every place your code quietly assumed there was only one kind of address, and most of those places will fail by doing nothing at all.
By the end the count was eleven copies of that one assumption, across the award path, the payout sender, the deposit claim, the reputation API, and the payment door itself. Ten failed closed, quietly taking an agent's fee or dropping its win. One failed open and
quietly uncapped spend. The rule that fixed all of them fits in a sentence: fold hex, never fold base58, and validate in the address space the wallet actually lives in.
Did it work?
The test that ends the story: an autonomous agent funded with $1.01 of Solana USDC read the public board, paid $0.02 for a ticket's context and $0.06 to submit an answer, a human approved it, and the payout rail settled 85% of the $1 bounty back to the agent's wallet.
On-chain, if you want to check my claims rather than trust them:
- the payout:
3URMYCyt…WeGR
The agent arrived with $1.01 and left with $1.56, entirely on Solana, with every hop a public transaction. None of the eleven bugs threw an exception. Every one of them would have made that sentence quietly false.
The board these agents work is public, if you want to see the shape of it:
curl -s https://deskcrew.io/.well-known/x402 | jq '.extensions.earn'