DEV Community

Dhiraj Chaudhry
Dhiraj Chaudhry

Posted on

The Web3 Recovery Bug Wasn’t in the Smart Contract

I recently worked on a crypto recovery flow where users could recover tokens sent to the wrong chain or unsupported wallet/account setup.

At first, the flow looked simple:

Detect the user wallet
Select the chain
Select the token
Check token balance
Enter recovery address
Build recovery transaction
Let the user sign and recover funds

The smart contract side was not the hardest part.

The harder part was handling all the small assumptions around chains, token contracts, account factories, RPCs, and user expectations.

The problem with “same address, same wallet”

In EVM chains, the same address can exist across Ethereum, Polygon, BNB Chain, Arbitrum, Base, etc.

But that does not mean the account behaves the same everywhere.

One address can have:

a smart account deployed on one chain
no contract deployed on another chain
a different factory on another chain
token balance on one network
zero balance on another
different gas requirements
different token contract mappings

So this assumption is dangerous:

`// bad assumption
const account = await detectAccount(userAddress);

A better approach is always chain-aware:

const account = await detectAccount({
address: userAddress,
chainId: selectedChain.id,
rpcUrl: selectedChain.rpcUrl,
});`

It looks small, but this kind of difference matters a lot in Web3 apps.

The bug: hardcoded chain detection

One issue I ran into was factory detection being tied to the wrong chain.

The app was checking an older smart account factory using Ethereum mainnet logic, then applying that result to other chains like BNB.

That created a weird failure:

Ethereum detection worked
BNB recovery failed
balance checks looked inconsistent
the UI made it seem like the user had no recoverable funds

The fix was tiny: use the selected chain’s RPC when detecting the factory.

`const provider = new JsonRpcProvider(selectedChain.rpcUrl);

const factory = await detectFactory({
provider,
accountAddress,
chainId: selectedChain.id,
});`

The lesson was simple:

Do not let one default RPC quietly control multi-chain logic.

Token symbols are not reliable

Another problem was token mapping.

Users often say:

I sent USDT.

But “USDT” does not always mean the same contract address on every chain.

For example, USDT on Ethereum and USDT on BNB Chain are different token contracts.

So this is not enough:

`if (token.symbol === "USDT") {
// fetch balance
}

You need chain-specific token mapping:

const TOKENS = {
ethereum: {
USDT: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
},
bnb: {
USDT: "0x55d398326f99059fF775485246999027B3197955",
USDC: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
},
};
`
The user sees a symbol.

The app must care about the actual contract address.

“$0 balance” is not always zero balance

This was another annoying edge case.

Sometimes the app showed $0 balance, but the real issue was not that the user had no funds.

It could be:

RPC rate limit
wrong chain selected
unsupported token contract
old factory mismatch
failed balance fetch
bad provider response
token list missing a contract address

Showing all of these as “0 balance” is bad UX.

A better pattern is to separate states clearly:

type BalanceState =
| { status: "loading" }
| { status: "success"; balance: string }
| { status: "zero" }
| { status: "rpc_error"; message: string }
| { status: "unsupported_token" }
| { status: "unsupported_chain" };

This makes debugging easier and prevents users from thinking their funds disappeared.

Keep signing local

For recovery flows, trust matters a lot.

Users are already nervous because money is involved. The app should be very clear about what happens locally and what goes to the backend.

My preferred approach:

backend can provide config
backend can provide supported chains/tokens
backend can prepare payload details
frontend handles wallet connection
signing stays local in the browser
sensitive signing material is never sent to the backend

The product message should be simple:

The app helps build the recovery transaction, but it does not custody your funds.

That line matters.

What I would do differently next time

If I were building this again from scratch, I would spend more time on the configuration layer before touching the UI.

Things I would define early:

supported chains
supported token contracts per chain
old vs new factory logic
RPC fallback strategy
balance state handling
account detection per chain
recovery failure logs
advanced debug info for support/admins

Most Web3 bugs are not dramatic.

A lot of them are boring configuration bugs with expensive consequences.

Final takeaway

The biggest lesson from this recovery flow was that a Web3 product can have clean contracts and still feel broken.

The smart contract may be correct.

But if the app:

checks the wrong chain
maps the wrong token
hardcodes the wrong RPC
hides real errors behind “$0 balance”
assumes addresses behave the same everywhere

then users will still think the product failed.

For multi-chain Web3 apps, the infrastructure around the contract is just as important as the contract itself.

Sometimes the bug is not in Solidity.

Sometimes it is just one hardcoded chain check.

Top comments (0)