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()
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:
- Donated ~36M THE directly to the vTHE contract — bypassing the supply cap entirely and inflating the exchange rate 3.81x
- Borrowed against the inflated collateral — CAKE, BNB, BTCB, USDC totaling ~$14.9M
- 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
- Flash loan large amount of DOLA
- Deposit DOLA into the sDOLA vault — inflating the exchange rate from ~1.189 to ~1.353 DOLA per sDOLA
- Trigger liquidations — the sudden exchange rate jump caused health factors of existing borrowers to collapse below zero
- Act as liquidator — the attacker's contract claimed liquidation rewards
- 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
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);
}
}
}
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
);
}
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
}
}
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;
}
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;
}
}
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
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;
}
Audit Checklist: Donation Attack Surface
When auditing a lending protocol or ERC-4626 vault, check each of these:
1. Exchange Rate Derivation
- [ ] Does
totalAssets()orgetCash()usebalanceOf()? - [ ] 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/depositpath 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?
- Fork-and-forget culture — Teams fork Compound or Aave v2, change the logo, and launch. The fork includes the original vulnerability.
- Audit scope limitations — Audits check the code that changed. If the donation vulnerability was in the original fork, it often isn't flagged.
- 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)