DEV Community

ohmygod
ohmygod

Posted on

Donation Attacks Are Back: How Venus Lost $3.7M and sDOLA Lost $240K in One Month — A Defense Guide for Lending Protocols

March 2026 delivered a brutal reminder: donation attacks against DeFi lending protocols aren't a theoretical concern — they're an active, evolving threat class. Within two weeks, two separate incidents exploited the same fundamental flaw in different ways, draining a combined ~$4M from Venus Protocol and Curve's LlamaLend.

This article dissects both attacks, extracts the common vulnerability patterns, and provides concrete defense implementations for protocol developers.


What Is a Donation Attack?

A donation attack exploits the gap between a vault's accounting system and its actual token balance. In Compound-style lending protocols and ERC-4626 vaults, the exchange rate between deposit tokens and shares is calculated as:

exchangeRate = totalAssets() / totalSupply()
Enter fullscreen mode Exit fullscreen mode

The critical insight: totalAssets() typically reads the contract's token balance, but totalSupply() only increases through the official deposit() or mint() path. If an attacker transfers tokens directly to the contract via a standard ERC-20 transfer(), totalAssets() increases while totalSupply() stays the same — inflating the exchange rate.

This is the foundation of every donation attack. The variations come from how the inflated exchange rate is weaponized.


Case Study 1: Venus Protocol THE Token ($3.7M, March 15)

The Setup (9 Months in the Making)

This wasn't a flash loan hit-and-run. The attacker spent nine months accumulating THE (Thena) tokens through multiple wallets funded via Tornado Cash, building a position that reached 84% of Venus's 14.5M supply cap.

Venus's supply cap only guarded the mint path — the standard deposit function. It didn't account for direct token transfers.

The Execution

At 11:55 UTC, the attacker deployed an attack contract that:

  1. Donated ~36M THE directly to the vTHE contract — bypassing the supply cap entirely and inflating the exchange rate 3.81x
  2. Borrowed against the inflated collateral — CAKE, BNB, BTCB, USDC totaling ~$14.9M
  3. Ran a recursive leverage loop — borrowed assets → swap to THE → donate → borrow more

The effective collateral position reached 367% of the intended supply cap.

Why Three Lines of Defense Failed

Line 1 — Supply Cap: Only enforced on mint(), trivially bypassed by direct transfer.

Line 2 — Oracle: Venus's Resilient Oracle initially rejected the manipulated price (Binance feed diverged from RedStone for ~37 minutes). But once the attacker sustained buy pressure across enough venues, both feeds converged at the elevated price. The oracle accepted ~$0.51 for a token worth ~$0.26.

Line 3 — Liquidation: 254 bots competed across 8,048 transactions. They seized THE collateral but couldn't sell it — the market had ~$2M of real depth for 53M tokens. Result: $2.15M in bad debt.

The Twist

The attacker also lost money. They invested $9.92M (borrowed from Aave via Tornado Cash funds) and retained only ~$5.2M after liquidations. An on-chain net loss of ~$4.7M. Both sides lost.


Case Study 2: sDOLA LlamaLend ($240K, March 2)

A Different Target, Same Principle

The sDOLA attack targeted Curve's LlamaLend market using sDOLA (staked DOLA) as collateral. Unlike Venus, this attack was surgical and fast — completed within a single transaction block using flash loans.

The Mechanism

  1. Flash loan large amount of DOLA
  2. Deposit DOLA into the sDOLA vault — inflating the exchange rate from ~1.189 to ~1.353 DOLA per sDOLA
  3. Trigger liquidations — the sudden exchange rate jump caused health factors of existing borrowers to collapse below zero
  4. Act as liquidator — the attacker's contract claimed liquidation rewards
  5. Repay flash loan, pocket the difference

The exploit netted ~$240K in WETH and DOLA. Borrowers using sDOLA as collateral were liquidated; lenders were unaffected.

The Key Difference from Venus

Venus's donation bypassed a supply cap. The sDOLA attack exploited how a lending protocol valued vault-based collateral. LlamaLend's price oracle derived sDOLA's value from the vault's exchange rate — which was directly manipulable through donations.

Same root cause. Different exploitation path.


The Common Vulnerability Pattern

Both attacks exploit a single architectural flaw:

Accounting relies on actual token balance
    ↓
Direct transfers inflate the balance
    ↓
Exchange rate/price increases without corresponding share issuance
    ↓
Attacker profits from the discrepancy
Enter fullscreen mode Exit fullscreen mode

This pattern appears in:

  • Compound forks (Venus, Benqi, Moonwell) — supply cap bypass + collateral inflation
  • ERC-4626 vaults (sDOLA, wUSDM, yield vaults) — share price manipulation
  • AMM LP tokens used as collateral — similar donation-based inflation

Defense Playbook: Concrete Implementations

Defense 1: Track Internal Balances (Don't Trust balanceOf)

The most robust fix: maintain your own accounting separate from the contract's actual token balance.

contract SecureLendingPool {
    // Internal accounting — NOT derived from balanceOf
    uint256 private _totalDeposited;

    mapping(address => uint256) private _deposits;

    function deposit(uint256 amount) external {
        IERC20(underlying).transferFrom(msg.sender, address(this), amount);
        _totalDeposited += amount;
        _deposits[msg.sender] += amount;
        // Mint shares based on _totalDeposited, NOT balanceOf
        uint256 shares = _calculateShares(amount, _totalDeposited);
        _mint(msg.sender, shares);
    }

    function totalAssets() public view returns (uint256) {
        // Ignore any "donated" tokens
        return _totalDeposited;
    }

    // Optionally: sweep excess tokens to treasury
    function sweepDonations() external onlyAdmin {
        uint256 excess = IERC20(underlying).balanceOf(address(this)) 
                         - _totalDeposited;
        if (excess > 0) {
            IERC20(underlying).transfer(treasury, excess);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Trade-off: Requires careful bookkeeping for every entry/exit path. Any code path that receives tokens must update _totalDeposited.

Defense 2: Virtual Shares (OpenZeppelin's Approach)

Add a "virtual" offset to both assets and shares to make inflation economically impractical:

// From OpenZeppelin's ERC4626 implementation
function _decimalsOffset() internal pure returns (uint8) {
    return 3; // 10^3 = 1000x inflation cost multiplier
}

function _convertToShares(uint256 assets, Math.Rounding rounding) 
    internal view returns (uint256) 
{
    return assets.mulDiv(
        totalSupply() + 10 ** _decimalsOffset(),  // Virtual shares
        totalAssets() + 1,                          // Virtual asset
        rounding
    );
}
Enter fullscreen mode Exit fullscreen mode

With a decimals offset of 3, an attacker needs to donate 1000x more tokens to achieve the same inflation effect. At offset 6, the attack becomes economically impossible for most tokens.

When to use: New vault deployments. Easy to implement. Doesn't prevent donation — makes it unprofitable.

Defense 3: Supply Cap on Actual Balance (Not Just Mint)

For Compound-fork protocols, enforce the cap on the total underlying balance, not just the minted amount:

function accrueAndCheckSupplyCap() internal {
    uint256 actualSupply = IERC20(underlying).balanceOf(address(this));
    uint256 mintedSupply = totalSupply() * exchangeRateStored() / 1e18;

    // Use the HIGHER of actual balance vs minted supply
    uint256 effectiveSupply = actualSupply > mintedSupply 
        ? actualSupply 
        : mintedSupply;

    require(effectiveSupply <= supplyCap, "Supply cap exceeded");
}

// Also: detect and flag donation events
function _checkForDonation() internal {
    uint256 balance = IERC20(underlying).balanceOf(address(this));
    uint256 expected = _internalTrackedBalance;

    if (balance > expected * 101 / 100) { // >1% deviation
        emit DonationDetected(balance - expected);
        // Option A: Pause the market
        // Option B: Cap the exchange rate increase
        // Option C: Redirect excess to reserves
    }
}
Enter fullscreen mode Exit fullscreen mode

Defense 4: Exchange Rate Circuit Breakers

Limit how fast the exchange rate can change per block or time window:

uint256 public lastExchangeRate;
uint256 public lastUpdateBlock;
uint256 public constant MAX_RATE_INCREASE_BPS = 100; // 1% per block

function exchangeRateCurrent() public returns (uint256) {
    uint256 rawRate = _calculateExchangeRate();

    if (lastUpdateBlock == block.number) {
        // Same block: cap the increase
        uint256 maxRate = lastExchangeRate * (10000 + MAX_RATE_INCREASE_BPS) / 10000;
        return rawRate > maxRate ? maxRate : rawRate;
    }

    // Different block: allow gradual increase
    uint256 blocksDelta = block.number - lastUpdateBlock;
    uint256 maxIncrease = MAX_RATE_INCREASE_BPS * blocksDelta;
    if (maxIncrease > 10000) maxIncrease = 10000; // Cap at 100%

    uint256 maxRate = lastExchangeRate * (10000 + maxIncrease) / 10000;
    uint256 effectiveRate = rawRate > maxRate ? maxRate : rawRate;

    lastExchangeRate = effectiveRate;
    lastUpdateBlock = block.number;

    return effectiveRate;
}
Enter fullscreen mode Exit fullscreen mode

Defense 5: Oracle-Level Protection for Vault Collateral

When using vault tokens (sDOLA, wstETH, etc.) as collateral, don't derive the price solely from the vault's exchange rate:

contract VaultCollateralOracle {
    uint256 public constant MAX_RATE_DEVIATION = 500; // 5%
    uint256 public cachedRate;
    uint256 public lastCacheTime;

    function getPrice(address vaultToken) external returns (uint256) {
        uint256 currentRate = IERC4626(vaultToken).convertToAssets(1e18);
        uint256 underlyingPrice = chainlinkOracle.getPrice(
            IERC4626(vaultToken).asset()
        );

        // CAPO: Correlated Asset Price Oracle pattern
        // Limit how much the rate can increase since last check
        if (cachedRate > 0) {
            uint256 timeDelta = block.timestamp - lastCacheTime;
            // Max 5% increase per hour for yield-bearing tokens
            uint256 maxRate = cachedRate * (10000 + MAX_RATE_DEVIATION * timeDelta / 3600) / 10000;

            if (currentRate > maxRate) {
                emit RateCapApplied(currentRate, maxRate);
                currentRate = maxRate;
            }
        }

        cachedRate = currentRate;
        lastCacheTime = block.timestamp;

        return currentRate * underlyingPrice / 1e18;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is essentially what Aave implemented with their CAPO (Correlated Assets Price Oracle) — limiting how quickly an asset's exchange rate can increase to prevent manipulation.


Detection: Monitoring for Active Attacks

On-Chain Monitoring Script

from web3 import Web3
import time

def monitor_donation_attacks(w3, vault_address, underlying_address, check_interval=12):
    """Monitor for donation attacks on Compound-style or ERC-4626 vaults."""

    underlying = w3.eth.contract(
        address=underlying_address,
        abi=ERC20_ABI
    )
    vault = w3.eth.contract(
        address=vault_address,
        abi=VAULT_ABI  # Includes exchangeRateCurrent or convertToAssets
    )

    prev_balance = underlying.functions.balanceOf(vault_address).call()
    prev_rate = vault.functions.exchangeRateCurrent().call()

    while True:
        time.sleep(check_interval)

        curr_balance = underlying.functions.balanceOf(vault_address).call()
        curr_rate = vault.functions.exchangeRateCurrent().call()

        # Detect donation: balance increased without corresponding deposit
        balance_delta = curr_balance - prev_balance
        rate_delta = (curr_rate - prev_rate) * 10000 // prev_rate  # basis points

        if balance_delta > 0 and rate_delta > 50:  # >0.5% rate jump
            alert(
                f"⚠️ POTENTIAL DONATION ATTACK\n"
                f"Vault: {vault_address}\n"
                f"Balance increase: {balance_delta / 1e18:.2f} tokens\n"
                f"Exchange rate jump: {rate_delta} bps\n"
                f"Block: {w3.eth.block_number}"
            )

        # Detect supply cap bypass
        if hasattr(vault.functions, 'supplyCap'):
            cap = vault.functions.supplyCap().call()
            if curr_balance > cap * 1.1:  # 10% over cap
                alert(
                    f"🚨 SUPPLY CAP BYPASSED\n"
                    f"Balance: {curr_balance / 1e18:.2f}\n"
                    f"Cap: {cap / 1e18:.2f}\n"
                    f"Excess: {(curr_balance - cap) / 1e18:.2f}"
                )

        prev_balance = curr_balance
        prev_rate = curr_rate
Enter fullscreen mode Exit fullscreen mode

Forta Detection Bot (Simplified)

const { Finding, FindingSeverity } = require("forta-agent");

const EXCHANGE_RATE_THRESHOLD = 100; // 1% in basis points

let previousRates = {};

function handleTransaction(txEvent) {
  const findings = [];

  // Monitor Transfer events TO vault contracts without corresponding Deposit
  const transfers = txEvent.filterLog("Transfer(address,address,uint256)");
  const deposits = txEvent.filterLog("Deposit(address,address,uint256,uint256)");

  for (const transfer of transfers) {
    const isToVault = MONITORED_VAULTS.includes(transfer.args.to);
    const hasMatchingDeposit = deposits.some(
      d => d.address === transfer.args.to && 
           d.args.assets.eq(transfer.args.value)
    );

    if (isToVault && !hasMatchingDeposit) {
      findings.push(Finding.fromObject({
        name: "Potential Donation Attack",
        description: `Direct transfer to vault ${transfer.args.to} without deposit event`,
        alertId: "DONATION-ATTACK-1",
        severity: FindingSeverity.High,
        metadata: {
          vault: transfer.args.to,
          amount: transfer.args.value.toString(),
          sender: transfer.args.from
        }
      }));
    }
  }

  return findings;
}
Enter fullscreen mode Exit fullscreen mode

Audit Checklist: Donation Attack Surface

When auditing a lending protocol or ERC-4626 vault, check each of these:

1. Exchange Rate Derivation

  • [ ] Does totalAssets() or getCash() use balanceOf()?
  • [ ] Can the exchange rate be manipulated by direct token transfers?
  • [ ] Is there a virtual shares/assets offset?

2. Supply Cap Enforcement

  • [ ] Is the supply cap enforced on the mint/deposit path only?
  • [ ] Is the cap checked against actual token balance or just internal accounting?
  • [ ] Can the cap be bypassed via direct transfer?

3. Oracle Integration (for vault-based collateral)

  • [ ] Does the price oracle derive value from the vault's exchange rate?
  • [ ] Is there a rate-of-change limit (CAPO pattern)?
  • [ ] Can the oracle be manipulated within a single block?

4. Liquidation Viability

  • [ ] Is there sufficient on-chain liquidity to liquidate the collateral token?
  • [ ] What happens if the collateral price crashes during mass liquidation?
  • [ ] Does the liquidation mechanism handle illiquid assets?

5. Monitoring & Response

  • [ ] Are exchange rate jumps monitored in real-time?
  • [ ] Can the protocol pause a specific market independently?
  • [ ] Is there an automated circuit breaker for abnormal rate changes?

The Bigger Picture

The Venus and sDOLA attacks share a disturbing commonality: both exploited vulnerabilities that were well-known and well-documented. The donation attack vector has been discussed since at least 2022. OpenZeppelin published their virtual shares defense in 2023. Aave implemented CAPO for vault-based collateral in 2024.

Yet protocols continue to ship without these defenses. Why?

  1. Fork-and-forget culture — Teams fork Compound or Aave v2, change the logo, and launch. The fork includes the original vulnerability.
  2. Audit scope limitations — Audits check the code that changed. If the donation vulnerability was in the original fork, it often isn't flagged.
  3. Economic analysis gaps — Smart contract audits verify code correctness. They rarely model multi-step economic attacks that combine supply cap bypass + oracle manipulation + market depth exploitation.

The fix isn't just technical. It's cultural:

  • Fork responsibly — Track known vulnerabilities in your upstream
  • Model economic attacks — Think like an attacker, not just a code reviewer
  • Monitor continuously — The Venus attacker accumulated for 9 months. On-chain signals were visible the entire time

The next donation attack is being set up right now. The question is whether your protocol will catch it.


Security research by the author. Follow for more DeFi security analysis, vulnerability breakdowns, and audit tooling guides.

Tags: defi, security, ethereum, web3

Top comments (0)