We built a page that empties old EVM wallets. You paste their seed phrases or private keys, and it finds the tokens with a market price on 15 chains, sells them, bridges the proceeds and delivers SOL to a Solana address.
The wallets people bring to it hold a few dollars of some token and almost never any native coin. That's the hard part. A sale has to go through from a wallet that can't pay for anything.
It works on 14 of the 15 chains. Why the fifteenth refused is the second half of this post.
Who pays the gas
Selling an ERC-20 takes an approve and a swap, and both cost gas. Funding fifteen chains with native coin just to sell $3 of dust on each is the chore the page is supposed to remove. So the gas had to come from somewhere else, and we saw two ways to do it:
- The app fronts gas from its own hot wallets on every chain and hopes to earn it back.
- A relayer submits the transaction and takes the gas out of what the sale returns.
We went with the second. There's no treasury sitting on fifteen chains waiting to be drained, and the user sees the gas cost in the quote before anything happens.
EIP-7702 and Calibur
A plain EOA can't batch calls, and it can't let someone else pay its gas. EIP-7702 lets the wallet delegate to a contract while keeping its address and key. Uniswap publishes Calibur, an audited delegate contract that does what we needed: it runs a batch of calls authorized by one EIP-712 signature from the wallet's own key.
Keys get parsed in the browser into viem local accounts. There's one input box with one secret per line. A line that's all hex and too long to be a seed word is treated as a private key, and anything else is tried as a BIP-39 phrase:
/** A line that is meant as a private key: one hex word, long enough not to be a seed word. */ const looksLikeKey = (line) => /^(0x)?[0-9a-f]{20,}$/i.test(line); From there, a sale goes like this:
- The browser signs a 7702 authorization that delegates the wallet to Calibur, once per wallet per chain.
- The browser signs one Calibur batch (EIP-712, with a deadline) holding the
approveand Relay's deposit call. - Our Cloudflare Worker sends it to Relay's
/executewithsubsidizeFees: false. Relay's relayer pays the gas and takes it back out of the sale.
The wallet only needs the token. Relay's fee plus gas is about $0.02 to $0.03 a sale on most chains and closer to $1.50 on Ethereum mainnet, so the page ticks every holding worth $1 or more and leaves anything smaller up to you.
Only forward an authorization the chain will apply
This is the failure that costs money, and it makes no noise. If the chain skips your authorization, the wallet has no code, the relayed call does nothing, and the relayer has paid gas for a no-op. A skipped authorization is easy to produce by accident, so the Worker refuses anything the chain wouldn't apply:
const code = await rpcRead(chain, "eth_getCode", [user, "latest"]); if (!delegatesToCalibur(code)) { // Delegated to some other smart account by another app: leave the wallet alone. if (code && code !== "0x") return json(400, { error: "This wallet is set up as a smart account already." }); if ( lower(a.address) !== lower(CALIBUR) || Number(a.chainId) !== chainId || BigInt(a.s) > SECP256K1_HALF_N || // high-s signatures are rejected on chain (Number(a.yParity) !== 0 && Number(a.yParity) !== 1) ) { return json(400, { error: "The delegation must be to Calibur on this chain." }); } const nonce = parseInt(await rpcRead(chain, "eth_getTransactionCount", [user, "pending"]), 16); if (Number(a.nonce) !== nonce) return json(409, { error: "The wallet's nonce moved. Sign the delegation again.", code: "nonce" }); const by = await recoverAuthorizationAddress({ authorization: auth }); if (!by || lower(by) !== lower(user)) return json(403, { error: "The delegation isn't signed by this wallet." }); authorizationList = [auth]; } Some of this we learned the hard way. The authorization nonce comes from the pending transaction count, and it moves if anything else touches the wallet between signing and submitting.
If the wallet already delegates to Calibur, we drop the authorization instead of sending it again, because a stale one in the list can fail the whole transaction. If it delegates to some other contract, we skip the wallet entirely. Overwriting another app's delegation from a dust tool is a good way to break something you don't understand.
Two legs through a hub chain
Relay prices same-chain gasless swaps differently from cross-chain ones. Same-chain needs the app to sponsor the gas and cross-chain doesn't. So every holding makes a cross-chain hop into ETH on Base, inside the same wallet. Tokens that are already on Base hop to Optimism instead.
Then one batch per wallet bridges the collected ETH to SOL, with our fee as a plain ETH transfer at the front of that batch. Compared with quoting each token straight to SOL, the first leg costs about the same relayer fee, so the real extra is the final bridge at about $0.02 per wallet. In return, each wallet pays one fee transfer, and between the two legs the money sits in the user's own wallet.
Relay in production
Relay's docs cover everything in this section. It's still easy to miss, and one of these broke our first parser.
A failure status isn't final. A failed fill usually gets retried or refunded, so the poller only treats success and refund as the end. The exception is a case Relay spells out itself:
if (s.status === "success") return { ...done, requestId }; if (s.status === "refund") return { status: "refunded", requestId, note: "Relay sent it back to the wallet." }; if (s.status === "failure" && s.refundFailReason && s.refundFailReason !== "N/A") { return { status: "failed", requestId, note: noRefund(s.refundFailReason) }; } refundFailReason holds the literal string "N/A" when there's nothing to report. Our first parser expected it to be empty and fell over, which is why that comparison is there.
Set refundTo on every quote. Without it there's no automatic refund path for a deposit Relay can't fill.
Rate limits matter too. /quote allows 50 requests a minute per app, so 429s during a run with lots of tokens are normal, and every call retries with backoff.
The chain that refused: Avalanche
Native AVAX sales failed in testing. Relay's dry run rejected them twice with a generic error, while native ETH and BNB went through on every other chain. The error told us nothing.
Hardhat's default test accounts gave it away. Their keys are public, so bots delegated them long ago on every chain that allows it. eth_getCode on account #0 returns delegation code on all 14 of our other chains, Scroll and Zora included. On Avalanche it returns 0x.
Avalanche C-Chain doesn't have EIP-7702 yet. ACP-209 is still a proposal. Every gasless sale there was never going to work, and we couldn't find an RPC method that tells you so. If you know a cleaner way to ask a chain whether it applies 7702 authorizations than poking known-delegated accounts for code, we'd like to hear it.
So on Avalanche we dropped the gasless path. AVAX itself is still worth selling the ordinary way, with the gas paid out of the AVAX being sold:
const balance = BigInt(await rpc(chainId, "eth_getBalance", [user, "latest"])); // A quarter over the current price so it confirms; what goes unused stays in the wallet. const gasPrice = (BigInt(await rpc(chainId, "eth_gasPrice", [])) * 5n) / 4n; const amount = balance - OWN_GAS_LIMIT * gasPrice; // OWN_GAS_LIMIT = 1,500,000 const est = BigInt(await rpc(chainId, "eth_estimateGas", [{ from: user, to: c.to, data: c.data, value }, "latest"])); const gas = (est * 13n) / 10n; const raw = await account.signTransaction({ type: "legacy", chainId, nonce, to: c.to, value, data: c.data, gas, gasPrice }); A sale through Relay's router used 480k to 740k gas in our tests, so the 1.5M reserve has room to spare, and whatever isn't used stays in the wallet as AVAX. ERC-20s on Avalanche can't be sold for now. The page tells you that up front instead of failing halfway through.
What the server can and can't do
The page asks for seed phrases, so we built it as if our own server were hostile. That's how any sensible user will think about it, and they're right to.
- Keys only ever exist in the browser tab. The server gets wallet addresses, holdings and signed payloads. The page's CSP lets scripts talk to its own origin and DexScreener's price API and nowhere else, which you can check in the response headers and the network tab.
- The Worker re-encodes every batch from its parsed form before forwarding it, so no padding or extra calldata can sneak along.
- Each batch has to match a quote the Worker issued itself (an HMAC over the calls, wallet, chain and deadline), recover to the wallet's own signature, and land before a deadline the server picked. The worst the Worker can do is refuse to send a sale. It can't change one or make one up.
- On the own-gas path, the broadcast endpoint only accepts a transaction whose call matches an HMAC-quoted call and that's signed by the quoted wallet.
Token discovery doesn't need paid keys either. Alchemy's demo tier lists balances but prices very few tokens, so prices come from DexScreener in the browser. When Alchemy can't answer for a chain at all, a Multicall3 pass reads the balances of every token on CoinGecko's list for that chain straight from the chain.
If you're building something like this
Settle who pays gas first. With subsidizeFees: false the relayer takes it out of the sale and you never hold a float on any chain.
Treat the authorization checks as the core of the whole thing. A skipped authorization costs real money and gives you no error to go on.
And probe every chain for 7702 support before you promise it. Everything else about Avalanche looked standard, and that's what made it hard to spot.
The tool is at https://migration.solstack.xyz if you have an old wallet with some dust in it.
