On April 17, 2022, one account drained about $182 million from Beanstalk by passing its own governance proposal with a $1 billion flash loan. The code it approved did not exist until one transaction before it ran, and the one-day emergency window counted votes while the polls were still open.
At 10:54:45 UTC on April 16, 2022, an Ethereum account filed a governance proposal with Beanstalk, a stablecoin protocol whose depositors voted on changes to its own code. The proposal, BIP-18, asked Beanstalk to run a function called init() at another address. Anyone who looked that address up that day would have found nothing there: zero bytes of code. Beside it, the same account proposed BIP-19, a $250,000 donation to Ukraine.
Twenty-five hours, twenty-nine minutes and thirty-one seconds later, at 12:24:16 UTC on April 17, the same account put two transactions into one block. The first placed code at the empty address. The second borrowed about $1 billion, turned it into voting power, executed BIP-18, emptied the protocol, repaid the loans and left. Beanstalk lost about $182 million.
Every one of those steps is still on the public chain, and the contract's source from before the attack is still in the project's GitHub history, so the attack can be read the way you would read a log. Read that way, the $1 billion loan that most of the coverage led with is the least interesting line in it.
Beanstalk's governance could execute a proposal two ways. The ordinary path, commit, ran after a vote that lasted 168 "seasons," which the code's own comment translates as seven days, and it needed a simple majority. The emergency path, emergencyCommit, could run once a proposal was one day old, if two-thirds of the voting weight supported it. Here it is, as it stood in the repository after its last pre-attack change on April 1, 2022:
function emergencyCommit(uint32 bip) external {
require(isNominated(bip), "Governance: Not nominated.");
require(
block.timestamp >= timestamp(bip).add(C.getGovernanceEmergencyPeriod()),
"Governance: Too early.");
require(isActive(bip), "Governance: Ended.");
require(
bipVotePercent(bip).greaterThanOrEqualTo(C.getGovernanceEmergencyThreshold()),
"Governance: Must have super majority."
);
_execute(msg.sender, bip, false, true);
}
The constants file sets the emergency period to 86400, with the comment // 1 day, and the threshold to two-thirds. Four checks, each doing its stated job: the proposal exists, it is at least a day old, its vote is still open, and two-thirds of the weight is behind it.
The difference between the two paths is one function call. The ordinary path computes its percentage with endedBipVotePercent, which divides the votes recorded when the proposal ended by the total weight recorded at that same moment. The emergency path uses bipVotePercent, which divides the votes for the proposal by the total weight in the protocol right now. One counts the ballots after the polls close. The other counts them while the polls are open, in the same transaction that can add ballots.
Jon Stephens, the chief executive of the audit firm Veridise, put the consequence plainly in a 2026 review of the case: "Every require holds: the proposal is nominated, old enough, still active, and past two-thirds. The problem is where the number in that last check comes from." Voting weight came from deposits in Beanstalk's "Silo," and a deposit made seconds earlier counted the same as one held for months.
The first transaction, in block 14595906, called propose. Its arguments are still readable: no code changes to Beanstalk itself, a target address beginning 0xe5ecf736, and four bytes of instructions, e1c7392a, which is the signature of a function named init(). The only requirement the proposal code placed on the target was that it not be the zero address. Nothing checked that anything lived there. The account also cast its own vote for the proposal automatically, because propose did that for every proposer.
The cost of entry was small. Proposing required 0.1 percent of the protocol's voting weight, and the blockchain analytics firm Elliptic traced the account's stake to 212,858.50 BEAN bought "with an initial 73 ETH investment."
The contract stored the proposal's timestamp, 10:54:45 UTC, so the emergency path opened at 10:54:45 UTC the next day. The account came back 89 minutes after it opened.
Block 14602790 holds 192 transactions, and two of them belong to that account. The first, at index 12, one ahead of the attack, went to a public deployment contract that places code at an address computed in advance from a salt and the code itself. Recomputing that address from the transaction's own input gives 0xe5ecf736..., the address BIP-18 had named a day earlier. For the whole time the proposal was open, the thing it would run did not exist. It arrived one transaction before it was used.
The second, at index 13, is the attack. The security firm CertiK's reconstruction of it reads like a checklist. Flash loans of 350 million DAI, 500 million USDC and 150 million USDT, which Immunefi traces to Aave, plus BEAN and LUSD from two exchanges. Conversion through Curve into the liquidity tokens Beanstalk accepted as deposits. Deposits that took the proposal past two-thirds; CertiK puts the result at "more than 78% of the vote." A call to emergencyCommit. Beanstalk then ran the attacker's init() inside its own context, which moved out 36,084,584 BEAN and hundreds of millions of the pool tokens holding the protocol's liquidity. The loans were repaid in the same transaction, $250,000 in USDC went to the Ukraine donation address, and 24,830 wrapped ether went to the attacker.
The transaction used 6,950,377 units of gas and emitted 121 log entries. It succeeded.
One of the deposit types it used was new. Among the attacker's deposits were 58,924,887 BEAN:LUSD pool tokens, and Beanstalk's constants file had last changed six days earlier, on April 11, in a commit titled "bip-16 (#66) Whitelist LUSD." The protocol's own governance had widened the set of things that counted as stake the week before someone rented the stake.
The one-day wait reads like a safety feature, and it was meant as one. It gave nobody anything to act on. Both proposals were, in Elliptic's words, "disguised as Ukraine donation proposals," and they "were already creating controversy amongst confused users before the theft." But the code BIP-18 would run could not be reviewed because it had not been deployed, and the weight that would pass it could not be seen because it had not been borrowed yet. Nothing a careful member could inspect during those 24 hours would have shown what was coming.
What the window did do was set the time. A delay without a snapshot does not ask whether the voters are the same people who were there when the proposal was filed. It asks only whether the clock has run. The attacker read the rule as written and came back an hour and a half after the door opened.
The ordinary path, by contrast, was never at risk from a loan that lasts one transaction, as the security platform Immunefi pointed out in its analysis: "When the proposal has ended already, stakers can no longer vote on it, meaning that the commit function is not susceptible to an attack leveraging flashloaned funds." Beanstalk had built the defense. It applied it to the slow door and left it off the fast one.
Veridise's review makes the point that matters most here: "The flash loan made the attack cheap, not possible. Any sufficiently large holder could have cleared the threshold." The flash loan let someone rent that holder's position for a single transaction. If the rule had been written differently, renting it would have been useless.
The money shows how lopsided the trade was. The attacker put up 73 ETH of their own. The protocol lost about $182 million, a figure CertiK, Elliptic and CoinDesk all report. The attacker kept far less, because draining a protocol whose assets are its own token destroys much of their value on the way out. Elliptic reported that BEAN fell from $1 to $0.10 and put the take at "just under 25,000 Ether (ETH), which is worth $76 million." The analytics firm PeckShield, quoted by CoinDesk that day, counted "$80+M," including 36 million BEAN. Both figures describe the same exit and differ mainly on whether to count the BEAN.
Beanstalk's own summary, posted to its Discord and quoted by CoinDesk, named the cause in one sentence: "Beanstalk did not use a flash loan resistant measure to determine the % of Stalk that had voted in favor of the [governance proposal]." Its code had been audited, and CoinDesk reported the auditing firm's own account that the audit "was completed before the introduction of the flash loan vulnerability." Afterward the team paused the protocol and, according to Immunefi, removed the governance facet entirely and "replaced that on-chain governance mechanism for a community-run multisig wallet."
None of this required a new idea. The standard governance contracts published by OpenZeppelin fix each voter's weight at a snapshot, which their documentation describes as the "Timepoint used to retrieve user's votes and quorum," and they pair it with a separate timelock that "gives time for users of the controlled contract to exit before a potentially dangerous maintenance operation is applied." The same library splits the power to propose an operation from the power to execute it.
Put those beside emergencyCommit and three missing properties stand out. Authority should be measured before the request exists, so weight acquired afterward counts for nothing. The thing being approved should exist and be readable for the whole time it can be approved. And the delay should protect the people affected, which means it should hold the action back while they read and react. A delay that releases the action automatically once the clock runs out does the opposite.
The same shape is worth looking for in agent systems, which are acquiring their own emergency paths. Picture an approval step for a risky tool call with a fallback like this: if no human answers within ten minutes, proceed; or, whoever is on call when the timer expires may approve. Add a tool call whose arguments are assembled at execution time rather than shown at approval time, and you have rebuilt emergencyCommit in miniature. The authority is read at call time. The payload was never visible to the approver. The timer releases the action instead of holding it.
The Beanstalk fixes translate directly. First, decide who may approve at the moment the request is filed, and do not let that set change while the request waits. Second, have the approver approve the exact action that will run, down to its arguments, and refuse to run anything that differs. Third, make the timer fail closed. When it expires without an answer, the request should be cancelled, not executed.
The empty address has an agent twin as well. An approval to run the deploy script approves a name, and whatever sits behind the name at execution time is what runs. Operating-system security has a name for that gap, time of check to time of use, and the standard fix is the one BIP-18 lacked: bind the approval to the content, for instance a hash of the exact script and arguments, and refuse to run anything else. A system built this way can still move fast in a real emergency. It just cannot be made to move by someone who merely waited.
Veridise's review turns on a question that works for any privileged path: who "can actually satisfy the checks," as opposed to whether the checks exist. For Beanstalk, the answer was anyone who could hold two-thirds of the weight for one transaction, and on Ethereum in 2022 that meant anyone.
Cryptographers settled a version of this in 1883. Auguste Kerckhoffs argued in the Journal des sciences militaires that a cipher "must not require secrecy and can be stolen by the enemy without causing trouble," in Fabien Petitcolas's English rendering. As Petitcolas notes, that does not mean the method has to be published, only that it should be "considered as public during its creation." Code on a public chain skips the question: it is public from the first block. A rule that anyone can read has to stay safe when the person reading it most carefully is the one who wants to break it.
So run that question against every emergency power you own, whether it is a governance function, a production override or an agent's escalation path. Write down its rule in one sentence, then read the sentence the way its most motivated user will. Beanstalk's said: one day, two-thirds of whoever is present, and whatever code the proposal names. The attacker read it that way.
For twenty-five hours, the most important argument in BIP-18 pointed at an empty address, and nothing in the contract required it to be otherwise.
Block times, the 25-hour gap and the code sizes at the target address come from onchain_cp0844.py; BIP-18's calldata and the recomputed deployment address come from onchain_create2_cp0844.py. Both read the transactions from a public Ethereum archive node; the raw block timestamps are 1650106485 and 1650198256.
Sources:
GovernanceFacet.sol and Bip.sol at commit ee4720cdb4 (April 1, 2022), and C.sol at commit f5e69b76cd (April 11, 2022), the last versions before the attack. https://github.com/BeanstalkFarms/Beanstalk/blob/ee4720cdb4/protocol/contracts/farm/facets/GovernanceFacet/GovernanceFacet.sol and https://github.com/BeanstalkFarms/Beanstalk/blob/ee4720cdb4/protocol/contracts/farm/facets/GovernanceFacet/Bip.sol and https://github.com/BeanstalkFarms/Beanstalk/blob/f5e69b76cd/protocol/contracts/C.solproposalSnapshot, TimelockController). https://docs.openzeppelin.com/contracts/5.x/api/governanceBeanstalk's emergency path read authority at call time and approved a name whose contents did not exist yet. Agent approval steps fail the same way when the approver sees a label instead of the exact arguments that will run. Binding each approval to the precise action is half the fix; the other half is a record of what was approved and what actually executed that nobody can quietly rewrite afterwards. Chain of Consciousness keeps a tamper-evident record of an agent's actions, so the two can be compared later.
pip install chain-of-consciousness npm install chain-of-consciousness
Or start without installing anything: Hosted Chain of Consciousness.