RoadToChain Logo
RoadToChain
T3/M3.4/Rate limiting and abuse prevention
intermediate 12m read

Rate limiting and abuse prevention

Designing gas faucets that prevent Sybil attacks. Why native gas payouts must be rate-limited and routed strictly to EOA addresses.

#faucets #rate-limiting #security #anti-fraud
Faucet Rate Limiting Proxy and EOA vs Smart Account Payouts
Bots easily exploit raw contract faucets. Wrap faucets in rate-limiting API proxies, and direct payouts strictly to EOA wallets to prevent out-of-gas reverts on smart contract accounts.

One of the greatest security challenges in Web3 is the Sybil Attack: a single user spawning thousands of automated bots to drain native tokens or spam on-chain smart contracts.

When Socio3 V2 migrated to an ERC-4337 Account Abstraction design, we wanted to make the app 100% free for users. To achieve this, we set up two systems:

  1. A Verifying Paymaster to sponsor transaction gas fees.
  2. A POL Gas Faucet to gift new users native gas tokens to fund their tipping wallets.

Within hours of launching the faucet, bots attempted to drain the entire faucet contract. This lesson details the defensive patterns required to protect Web3 gateways from bot abuse.


1. The Faucet Rate Limiting Proxy

You should never allow a frontend client to request gas tokens directly from an on-chain faucet contract. If you do, bots will scan your frontend code, extract the payout trigger, and loop it programmatically.

In Socio3, faucet requests are proxied through a rate-limited Express endpoint (POST /api/faucet).

SmartAccount.sol
text
React Client ──▶ POST /api/faucet ──▶ Express Backend ──▶ Rate Limiter ──▶ Payout Transaction

                                                       Checks Firestore for Cooldown

Here is the implementation:

index.js
javascript
// backend/routes/faucet.js
const express = require('express');
const { collection, query, where, getDocs, addDoc } = require('firebase/firestore');
const { db } = require('../config/firebase');
 
const router = express.Router();
const COOLDOWN_HOURS = 24;
 
router.post('/faucet', async (req, res) => {
  const { recipientAddress } = req.body;
 
  if (!recipientAddress || !recipientAddress.startsWith('0x')) {
    return res.status(400).json({ error: "Invalid recipient address" });
  }
 
  try {
    // 1. Query Firestore to check if this address has requested gas recently
    const faucetRef = collection(db, "faucet_requests");
    const q = query(
      faucetRef,
      where("address", "==", recipientAddress.toLowerCase()),
      where("timestamp", ">", Date.now() - COOLDOWN_HOURS * 60 * 60 * 1000)
    );
    
    const querySnapshot = await getDocs(q);
    if (!querySnapshot.empty) {
      return res.status(429).json({ error: "Faucet limit reached. Try again in 24 hours." });
    }
 
    // 2. Perform Faucet Payout (Proxying to Polygon Amoy faucet API)
    const payoutTx = await executePayout(recipientAddress);
 
    // 3. Record transaction in Firestore to enforce cooldown
    await addDoc(faucetRef, {
      address: recipientAddress.toLowerCase(),
      timestamp: Date.now(),
      txHash: payoutTx.hash
    });
 
    return res.status(200).json({ success: true, txHash: payoutTx.hash });
  } catch (error) {
    return res.status(500).json({ error: "Faucet transaction failed" });
  }
});

2. The Smart Contract Revert Trap (EOA Only)

A crucial architectural discovery in Socio3 V2 was that faucets must never send native tokens directly to a deployed Smart Account contract.

Here is why:

  • Standard faucet transfers use a plain transaction with a fixed gas limit of 25,000 gas.
  • If the recipient is an EOA (Externally Owned Account like MetaMask), the transfer consumes exactly 21,000 gas and succeeds.
  • If the recipient is a Smart Account contract, the transfer invokes the contract's receive() or fallback() function. This delegates execution to the implementation contract, consuming >25,000 gas. The transaction reverts silently due to out-of-gas limits.
SmartAccount.sol
text
Faucet ──(25k Gas Transfer)──▶ MetaMask EOA (21k gas) ──────────▶ Success!
Faucet ──(25k Gas Transfer)──▶ Smart Account Contract (>25k gas) ──▶ Out of Gas (Revert)

The Design Solution: Always route faucet payouts to the user's EOA signer wallet (privyUserAddress). Once the EOA has funds, display a "Bridge to Smart Account" card in the UI, allowing the user to initiate a contract call to transfer the funds to their Smart Account.


// Reality Check

Web3 rate limiting requires off-chain state. A blockchain has no concept of time-based IP rate limits, and checking cooldowns in Solidity costs gas. Always use a hybrid backend (like Firestore or Redis) to rate limit requests before they touch the blockchain.

— Production Engineering Principle

System Design Challenge
Think Active

If a malicious user attempts to bypass your Firestore rate limiter by generating 10,000 new random EOA addresses, how would you protect your faucet? Hint: Think about OAuth verification (like Privy social logins) or Gitcoin Passport scores.

[ Think Before Continuing ]

Was this lesson helpful?

Let us know what you think of this specification. (submitting anonymously)