Testnet Approval and Revoke Drills

Why I rehearse approvals before mainnet

Approvals are the most common permission I grant in DeFi, and they are also the easiest place to make a lazy mistake. If I can’t explain exactly who is allowed to spend, for how much, and how I’ll revoke it, I don’t sign the approval.

This post walks through the rehearsal I run on testnet: approving a spender, verifying the allowance on-chain, then revoking it. I also log gas costs from receipts so I can budget the mainnet run without guessing.

How ERC-20 approvals actually work

An ERC-20 approval writes an allowance entry keyed by (owner, spender) inside the token contract. The spender never holds your tokens, but once approved they can call transferFrom up to the allowance amount. The core functions are approve, allowance, and transferFrom as defined in the ERC-20 standard. ERC-20{target="_blank" rel=“noopener noreferrer”}

Two details I keep front of mind:

  • Allowance updates are state changes. Every approval is a transaction that costs gas and can fail.
  • The race condition warning is real. The ERC-20 spec recommends setting an allowance to zero before updating it to a new value to avoid a spender racing your update. ERC-20 allowance note{target="_blank" rel=“noopener noreferrer”}

When I rehearse on testnet, I follow the same flow I’ll use on mainnet:

  1. Pick a token contract and spender address.
  2. Read the current allowance.
  3. Approve a capped amount.
  4. Verify the allowance.
  5. Revoke by approving 0.
  6. Re-check the allowance and store the receipt gas data.

Ethers.js script for approvals, checks, and revokes

This is the script I run on Sepolia (or any testnet). It reads the allowance, approves a capped amount, logs the gas used from the receipt, then revokes by approving 0.

// npm install ethers@6 dotenv
import { config } from "dotenv";
import { ethers } from "ethers";

config();

const RPC_URL = process.env.RPC_URL;
const PRIVATE_KEY = process.env.PRIVATE_KEY;

// Example values: replace with a testnet ERC-20 and the contract you plan to approve
const TOKEN_ADDRESS = "0x0000000000000000000000000000000000000000";
const SPENDER_ADDRESS = "0x0000000000000000000000000000000000000000";
const DECIMALS = 6; // match the token's decimals

const ABI = [
  "function approve(address spender, uint256 amount) external returns (bool)",
  "function allowance(address owner, address spender) external view returns (uint256)",
  "function decimals() external view returns (uint8)",
];

const main = async () => {
  const provider = new ethers.JsonRpcProvider(RPC_URL);
  const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
  const token = new ethers.Contract(TOKEN_ADDRESS, ABI, wallet);

  const owner = await wallet.getAddress();
  const currentAllowance = await token.allowance(owner, SPENDER_ADDRESS);
  console.log(`Current allowance: ${currentAllowance.toString()}`);

  const approveAmount = ethers.parseUnits("25", DECIMALS); // cap at a small testnet amount
  const approveTx = await token.approve(SPENDER_ADDRESS, approveAmount);
  const approveReceipt = await approveTx.wait();

  console.log(`Approve gas used: ${approveReceipt.gasUsed.toString()}`);
  console.log(
    `Approve cost (wei): ${approveReceipt.gasUsed * approveReceipt.effectiveGasPrice}`
  );

  const updatedAllowance = await token.allowance(owner, SPENDER_ADDRESS);
  console.log(`Updated allowance: ${updatedAllowance.toString()}`);

  // Revoke by setting allowance to 0
  const revokeTx = await token.approve(SPENDER_ADDRESS, 0n);
  const revokeReceipt = await revokeTx.wait();

  console.log(`Revoke gas used: ${revokeReceipt.gasUsed.toString()}`);
  console.log(
    `Revoke cost (wei): ${revokeReceipt.gasUsed * revokeReceipt.effectiveGasPrice}`
  );

  const finalAllowance = await token.allowance(owner, SPENDER_ADDRESS);
  console.log(`Final allowance: ${finalAllowance.toString()}`);
};

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

Notes I keep in the repo next to this script:

  • Always cap approval size under the amount I plan to trade or deposit. Even on testnet I treat it as if it were real money.
  • Store the gasUsed and effectiveGasPrice from receipts to estimate mainnet cost with current base fees.
  • If a UI encourages “unlimited” approvals, I still rehearse the revoke path first so I know it works.

Risk analysis: approvals are a permission surface

Technical risks

  • A malicious or compromised spender can drain up to the allowance immediately after approval.
  • Some tokens implement non-standard approval logic; always verify allowance reads match what you expect. ERC-20{target="_blank" rel=“noopener noreferrer”}

Economic risks

  • Unlimited approvals create open-ended exposure; a single exploit can wipe the balance of that token.
  • If your approval is too tight, transactions can revert and waste gas. I rehearse with a small buffer, then cap on mainnet.

Operational risks

  • Gas spikes can make even revoke transactions expensive, delaying cleanup.
  • UI shortcuts may hide the actual spender address; I verify the exact address on the contract’s explorer page before approving.
  • Revoke tooling can fail if the token contract is paused or upgraded unexpectedly, so I keep a manual approve-to-zero path ready. Etherscan Token Approvals{target="_blank" rel=“noopener noreferrer”}

Practical takeaways from the drill

  • I only approve the exact amount I plan to use, and I capture the gas cost in testnet receipts to budget mainnet.
  • The approval → verify → revoke loop is fast; I can run it in minutes and it catches UI shortcuts.
  • If a dApp can’t explain why it needs an approval, I do not proceed.
  • For ongoing positions, I schedule revokes as part of the exit checklist and keep a notes file with spender addresses.

Resources