DEV Community

Cover image for How to Fully Transfer ERC-20 Tokens Without Leaving Dust Using MetaMask and JavaScript
Yuanyan Wu
Yuanyan Wu

Posted on

How to Fully Transfer ERC-20 Tokens Without Leaving Dust Using MetaMask and JavaScript

Learn how to fully transfer your ERC-20 token balance from one Ethereum wallet to another using MetaMask and raw JavaScript, leaving no residual dust. This tutorial walks you through the process using Chrome DevTools.

⚠️ Prerequisite: Fund the Wallet With ETH

ERC-20 token transfers require ETH to pay gas fees.

💸 How Much ETH Do You Need?

Action Gas Estimate ETH at 25 gwei ETH at 40 gwei
ERC-20 transfer ~50,000–60,000 gas ~0.00125 ETH ~0.002 ETH

To be safe, fund the source wallet with ~0.01 ETH. If gas prices are high, consider waiting for a weekend or late-night window when network demand is lower.

🔧 Step-by-Step: Transfer Full ERC-20 Balance

✅ Step 1: Open Chrome and Unlock MetaMask

Log in to MetaMask using your source wallet (the one holding the ERC-20 balance and ETH).

✅ Step 2: Open DevTools → Console (F12 or Ctrl+Shift+I)

In the Console tab, type:

window.ethereum
Enter fullscreen mode Exit fullscreen mode

If it's undefined, follow the fixes below.

🧰 Fixing MetaMask Injection Issues

If window.ethereum is not available:

✅ Check MetaMask Extension
Go to chrome://extensions/ and verify:

  • MetaMask is installed
  • It’s enabled
  • Not in an error or paused state

✅ Step 2: Use a Real HTTPS Page
MetaMask doesn't inject into:

✅ Step 3: Ensure MetaMask Permissions

  • Click the MetaMask icon
  • If it says "Connect to site", click Connect
  • Refresh the page

✅ Step 3: Paste This Script in DevTools

Once window.ethereum is available, paste the following script into the console:

(async () => {
  const tokenAddress = "0xERC20ContractAddress"; // Replace with actual ERC-20 contract
  const targetAddress = "0xTargetAddress";       // Destination address

  const [sourceWallet] = await ethereum.request({ method: 'eth_requestAccounts' });

  // 1. Fetch token balance
  const paddedAddr = sourceWallet.slice(2).padStart(64, '0');
  let balance = 0n;
  try {
      const balanceHex = await ethereum.request({
          method: 'eth_call',
          params: [{ to: tokenAddress, data: '0x70a08231' + paddedAddr }, 'latest']
      });
      balance = BigInt(balanceHex);
      console.log("Raw balance:", balance.toString());
  } catch (e) {
      console.error("Failed to read token balance:", e.message);
      return;
  }
  // 2. Build transfer() data
  const toPadded = targetAddress.slice(2).padStart(64, '0');
  const valuePadded = balance.toString(16).padStart(64, '0');
  const txData = '0xa9059cbb' + toPadded + valuePadded;

  // 3. Send transaction
  const txHash = await ethereum.request({
    method: 'eth_sendTransaction',
    params: [{
      from: sourceWallet,
      to: tokenAddress,
      data: txData
    }]
  });

  console.log("✅ ERC-20 full transfer sent. TX Hash:", txHash);
})();
Enter fullscreen mode Exit fullscreen mode

🔍 Deep Dive: Understanding eth_call, Function Selectors, and ABI Encoding

After you paste and run the script, it may seem magical — but here's how it works under the hood.

🧠 What is eth_call?

eth_call is an Ethereum JSON-RPC method that simulates a contract call locally without broadcasting a real transaction. It lets us read data from smart contracts (like balances or allowances) without paying gas.

🧾 Understanding data: 0x70a08231

Every function in a smart contract is identified by a 4-byte function selector, which is the first 4 bytes of the keccak256 hash of the function signature.

Example for balanceOf(address):

  • Function Signature: "balanceOf(address)"
  • keccak256: 0x70a082310000000000...
  • Selector: 0x70a08231
  • So calling balanceOf(address) with ABI-encoded arguments begins with 0x70a08231.

📚 Common ERC-20 Function Selectors

Function Signature Selector
Get token balance balanceOf(address) 0x70a08231
Transfer tokens transfer(address,uint256) 0xa9059cbb

You can generate these with tools like: https://pi7.org/hash/keccak-256

Just paste in the raw signature string, like "balanceOf(address)"

📦 ABI Argument Encoding

Smart contract arguments must be padded to 32 bytes (64 hex characters). For example:

If calling balanceOf("0x1234...5678"), the full data field is:

0x70a08231
+ 0000000000000000000000001234567890abcdef1234567890abcdef12345678
Enter fullscreen mode Exit fullscreen mode
  • The first part 0x70a08231 is the function selector
  • The second part is the address, left-padded with zeros to 32 bytes

To get the ERC-20 token balance:

const paddedAddr = wallet.slice(2).padStart(64, '0');
const data = '0x70a08231' + paddedAddr;
Enter fullscreen mode Exit fullscreen mode

To send the full balance via transfer():

const toPadded = target.slice(2).padStart(64, '0');
const amountPadded = balance.toString(16).padStart(64, '0');
const txData = '0xa9059cbb' + toPadded + amountPadded;
Enter fullscreen mode Exit fullscreen mode

Understanding this encoding lets you interact with any smart contract method manually — without relying on third-party libraries.

✅ Confirm and Sign in MetaMask

MetaMask will prompt you to approve the token transfer.

Once confirmed and mined, your ERC-20 balance will be fully transferred, with no dust left behind.

🧠 Why Not Just Use Wallet UIs?

Most wallets (like MetaMask, Rabby, etc.) limit the number of decimal places shown or used during transfers. As a result, they may leave small residual amounts (“dust”) in the wallet to avoid failed transactions due to precision or gas misestimation.

By using raw eth_call and manual ABI encoding, you can:

  • Move the exact token amount
  • Pay only the required gas
  • Leave nothing behind

🔖 Bookmark This for Future Sweeps

Whether you're rotating wallets, archiving an old address, or just OCD about balance dust — this technique gives you full control.

🧹 Clean wallet, clean mind.

As a prerequisite, you may want to review my earlier guide: How to Fully Empty a Wallet via Chrome Console Without Leaving Dust.

Top comments (0)