On April 17, 2022, an attacker borrowed nearly $1 billion in a flash loan, used it to acquire a supermajority of Beanstalk's governance token in a single transaction, passed a proposal that sent the entire treasury to their own wallet, and repaid the loan — all within one block. The $182M drain happened before any human could react. Beanstalk's governance worked exactly as designed. The design was the vulnerability.
Token economic attacks are not a niche threat. Governance controls minting authority, protocol revenue distribution, and treasury allocation. An attacker who controls governance controls the money printer. This post covers the six most dangerous token economic attack classes, the code patterns that enable them, and the defenses that actually work.
Why Token Economics Are an Attack Surface
Most developers treat tokenomics as a product concern — how much supply, how it vests, what it does. Security teams treat it as a contract concern — does mint() have access control, does burn() check balances. Both views miss the real risk: the intersection of governance power and economic primitives.
Three structural facts make token economics dangerous:
Governance controls minting. In most protocols, the entity with governance authority can also pass proposals that call mint(). If an attacker captures governance, they can inflate supply without limit.
Protocol revenue flows through contracts. Fee distributions, liquidity rewards, and treasury allocations are on-chain. A malicious governance proposal can redirect those flows in a single transaction.
Token distribution is a security property. If 30% of governance tokens are held by five addresses, those five addresses can collude to pass any proposal — including ones that steal from the remaining 70%.
Understanding this means evaluating every onlyGovernance or onlyOwner function as a potential attack target, not just an administrative interface.
Attack 1: Uncapped Minting via Governance
The most direct path: win the vote, mint what you want.
// VULNERABLE: no cap on minting
contract InflatableToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount); // No maxSupply check — prints forever
}
}
// VULNERABLE governance proposal
function proposeMassiveMint(address treasury) external {
// Attacker passes proposal to call token.mint(attacker, type(uint256).max)
// If governance is captured, this succeeds
}
Fei Protocol faced a variant of this in its early design: TRIBE governance tokens could be minted by governance vote without hard supply enforcement in the contract itself. The protection was social (multisig veto), not cryptographic.
An attacker with majority voting power passes a single proposal: mint N tokens to attacker-controlled address. They then dump those tokens on the open market. Every existing holder's position is diluted to near zero. The attacker extracts value equal to the market cap of the original supply.
Attack 2: Flash Loan Governance Attack — Beanstalk ($182M)
Beanstalk used a same-block governance mechanism: a proposal could be created and executed within the same transaction if the proposer held enough Stalk (governance tokens).
The attacker's transaction:
Block 14602790:
1. Flash borrow ~$1B in USDC/USDT/BEAN via Aave and other protocols
2. Use borrowed funds to buy 79% of circulating Stalk in a single transaction
3. Submit BIP-18: "Donate protocol reserves to Ukrainian relief fund"
(actual calldata: transfer all reserves to attacker address)
4. Vote FOR with 79% stake — instant supermajority
5. Execute BIP-18 immediately (no timelock delay)
→ $182M in reserves transferred to attacker
6. Repay flash loans
7. Net profit: ~$76M after costs
The vulnerability was not in the voting math — it was in two architectural decisions. First, voting power was based on current token balance, not a historical snapshot. Second, the protocol had an emergency governance path with no timelock.
// VULNERABLE: voting power reads current state
function getVotingPower(address account) public view returns (uint256) {
return stalk.balanceOf(account); // Current balance — flash loanable
}
// VULNERABLE: emergency path bypasses timelock
function emergencyCommit(uint256 bip) external {
require(isEmergency(bip), "Not emergency");
// Executes immediately — no delay
_execute(bip);
}
The fix is well-established: use ERC20Votes checkpointing so voting power is measured at the block before proposal creation, and remove any same-block execution path.
Attack 3: Treasury Drain via Governance
Treasury drain proposals look legitimate. They're often structured as grants, partnerships, or "protocol-owned liquidity" moves. The calldata points to an attacker-controlled address.
// What a malicious proposal calldata looks like when decoded:
// targets: [treasury]
// values: [0]
// calldatas: [abi.encodeCall(Treasury.transfer, (USDC, attackerAddress, totalBalance))]
// description: "Partnership with XYZ Foundation — initial disbursement"
Without a timelock long enough for the community to identify and veto the proposal, execution is automatic. Protocols that use 24-hour timelocks are vulnerable to low-participation governance windows (weekends, holidays). A well-resourced attacker times the proposal to minimize active voter attention.
Real example: multiple smaller DAO treasury drains in 2023-2024 followed this exact pattern — a new participant accumulates tokens over weeks, builds apparent legitimacy, then proposes a "grant" with a real-sounding description.
Attack 4: Missing maxSupply in mint()
Access control on mint() is necessary but not sufficient. The role itself can be granted too broadly.
// VULNERABLE: MINTER_ROLE granted to any contract that asks
contract BroadMinterRegistry {
IToken public token;
// Any verified "partner" can get minter role
function registerPartner(address partner) external onlyOwner {
token.grantRole(token.MINTER_ROLE(), partner);
// No tracking of how much each partner has minted
// No cap per partner
}
}
// VULNERABLE: no supply ceiling
contract VulnerableToken is ERC20 {
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount); // Minter can print unlimited tokens
}
}
If any contract with MINTER_ROLE is compromised — or if the role is granted to a contract with a reentrancy vulnerability — the attacker can drain all value by minting tokens faster than the market can absorb them.
Attack 5: Inflation via Fee Manipulation
Many DeFi protocols route trading fees to liquidity providers. If fee parameters are governance-controlled, an attacker with majority governance can set fees to 100% and effectively drain LP positions over time — or do it instantly if fee collection is on-demand.
// VULNERABLE: fee rate is governance-settable with no cap
contract FeeController {
uint256 public lpFeeRate; // Basis points — should be capped
function setFeeRate(uint256 newRate) external onlyGovernance {
lpFeeRate = newRate; // No upper bound — can be set to 10000 (100%)
}
}
// In the AMM:
function swap(uint256 amountIn) external returns (uint256 amountOut) {
uint256 fee = (amountIn * feeController.lpFeeRate()) / 10000;
// With 100% fee: fee == amountIn, amountOut == 0
// Fee goes to "liquidity providers" — which the attacker controls via governance
_collectFee(fee);
return amountIn - fee;
}
The attacker does not need to steal from the treasury directly. They redirect protocol revenue streams, then extract value through the fee collection mechanism they now control.
Attack 6: Token Migration Rug
Token migration events — moving from V1 to V2 tokens — are trust-intensive. Users are asked to deposit their old tokens into a migration contract in exchange for new ones. If the migration contract is malicious or incorrectly written, it becomes a drain.
// VULNERABLE: migration contract with non-1:1 conversion
contract MigrationContract {
IERC20 public oldToken;
IERC20 public newToken;
uint256 public conversionRate; // Governance-settable
function migrate(uint256 amount) external {
oldToken.transferFrom(msg.sender, address(this), amount);
// Backdoor: conversion rate can be set to 1 by governance
// Users deposit 1000 old tokens, receive 1 new token
uint256 newAmount = amount / conversionRate;
newToken.transfer(msg.sender, newAmount);
}
// "Emergency" function — drains unclaimed old tokens to team wallet
function recoverTokens(address to) external onlyOwner {
oldToken.transfer(to, oldToken.balanceOf(address(this)));
}
}
The attack has two stages: set conversionRate to a punishing number via governance, then call recoverTokens to drain all deposited old tokens to an attacker-controlled address. Users who migrated receive nearly nothing.
Defenses
Timelock on All Governance Actions (48-Hour Minimum)
A timelock does not prevent a malicious proposal from passing — it creates a reaction window. Community members, multisig guardians, or automated monitoring systems can veto proposals before execution.
48 hours is the practical minimum for a liquid protocol. For protocols with illiquid governance tokens (harder to acquire quickly), 24 hours may be acceptable. Less than 24 hours is indefensible.
// OpenZeppelin TimelockController — use this, don't roll your own
TimelockController timelock = new TimelockController(
48 hours, // minDelay
proposers, // addresses that can queue
executors, // addresses that can execute after delay
admin // address that can update (set to address(0) to renounce)
);
Quorum Requirements
Set quorum as a percentage of past total supply (not current), and set it high enough that flash loan attacks require borrowing more than exists.
function quorum(uint256 blockNumber) public view override returns (uint256) {
// 10% of supply at proposal snapshot — use getPastTotalSupply, not totalSupply()
return (token.getPastTotalSupply(blockNumber) * QUORUM_NUMERATOR) / QUORUM_DENOMINATOR;
}
Vote Delegation Limits and Snapshot Enforcement
Use ERC20Votes so voting power is measured at a historical snapshot. Flash loans acquired after the snapshot block carry zero voting weight.
Consider requiring a minimum holding period before tokens count toward governance — tokens held for less than one epoch should not vote.
Guardian / Veto Mechanism
A multisig guardian with veto power over queued proposals provides a backstop against malicious governance outcomes. The guardian should be limited to cancellation only — it should not be able to execute or drain.
function cancel(uint256 proposalId) external {
require(msg.sender == guardian, "Not guardian");
// Veto — proposal moves to Cancelled state, cannot be re-executed
_cancel(proposalId);
}
Supply Cap Enforced in Contract
The maxSupply check must live in the token contract, not in off-chain policy.
Vulnerable vs. Secure Mint Function
// VULNERABLE: no ceiling, role granted too broadly
contract VulnerableToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount); // No cap — any MINTER_ROLE holder can inflate supply
}
function grantMinterRole(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
grantRole(MINTER_ROLE, account); // No tracking of total granted minting power
}
}
// SECURE: hard cap in contract, per-minter limits, renounced admin
contract SecureToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
uint256 public immutable MAX_SUPPLY;
mapping(address => uint256) public mintedBy;
mapping(address => uint256) public mintCap;
constructor(uint256 maxSupply) ERC20("SecureToken", "SCT") {
MAX_SUPPLY = maxSupply;
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
require(mintedBy[msg.sender] + amount <= mintCap[msg.sender], "Exceeds minter cap");
mintedBy[msg.sender] += amount;
_mint(to, amount);
}
function setMinterCap(address minter, uint256 cap) external onlyRole(DEFAULT_ADMIN_ROLE) {
// Caps are additive, not replaceable — prevents silent cap increase
require(cap <= MAX_SUPPLY, "Cap exceeds max supply");
mintCap[minter] = cap;
}
}
Key differences: MAX_SUPPLY is immutable (cannot be changed after deployment), per-minter caps are tracked separately from the global ceiling, and there is no path for a single governance action to print unlimited tokens.
What ContractScan Detects
| Vulnerability Pattern | Detection |
|---|---|
mint() without maxSupply enforcement |
Uncapped mint — flagged as high severity |
MINTER_ROLE grantable by governance vote |
Overly broad role assignment — flagged with remediation |
| No timelock on treasury-moving functions | Missing timelock — flagged with recommended delay |
| Voting power based on current balance (not snapshot) | Flash loan governance surface — flagged as critical |
| Fee parameters without upper bound | Uncapped fee manipulation — flagged as medium severity |
| Migration contracts with non-1:1 conversion or admin drain | Migration rug pattern — flagged as high severity |
Static analysis tools catch some of these patterns in isolation, but token economic attacks are architectural. The threat is not a single vulnerable line — it is the combination of a governance mechanism, a minting function, and an economic incentive. ContractScan's AI engine reasons about the full system: who can call what, under what conditions, and what the economic consequence is.
Scan your token contract at https://contract-scanner.raccoonworld.xyz before deployment. Uncapped minting and missing timelocks are fixable in hours — after a governance attack, they are not.
Related: DeFi Governance Attack Vectors: On-Chain Voting Exploits
Related: Flash Loan Attack Deep Dive
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.