AMM Invariant Drift: How Fee Accumulation and Donation Attacks Break Constant-Product Assumptions
The constant-product invariant — `x * y = k` — is the mathematical backbone of every Uniswap V2-style AMM. It is elegant, deterministic, and, in pure mathematical terms, unbreakable.
Available in Português
AMM Invariant Drift: How Fee Accumulation and Donation Attacks Break Constant-Product Assumptions
Legal & Ethical Disclaimer
This content is provided for EDUCATIONAL and AUTHORIZED SECURITY TESTING purposes only.
- •Use these techniques on systems you own or have explicit written permission to test
- •Practice in authorized lab environments (VulnHub, HackTheBox, DVWA, etc.)
- •Follow responsible disclosure practices when finding vulnerabilities
- •Use knowledge for defensive security and authorized penetration testing
- •Access systems without explicit authorization
- •Use these techniques for malicious purposes
- •Deploy exploits against production systems you don't own
- •Share working exploits for unpatched vulnerabilities
Legal warning
Unauthorized access to computer systems is illegal in most jurisdictions (e.g. CFAA in the US, Computer Misuse Act in the UK). Violators may face criminal prosecution and civil liability. The author and publisher assume no liability for misuse of this information. By continuing, you agree to use this knowledge ethically and legally.
Hook & Context
The constant-product invariant — x * y = k — is the mathematical backbone of every Uniswap V2-style AMM. It is elegant, deterministic, and, in pure mathematical terms, unbreakable. The problem is that real AMM contracts don't live in pure mathematical terms. They live on the EVM, where integer division truncates, where protocol fees accumulate as discrete token increments, and where anyone can call transfer() to shove tokens into a pool's balance without going through the swap router. Each of these forces causes real reserves to diverge — sometimes by a few wei, sometimes by enough to reroute an arbitrage path — from the theoretical k that the contract believes it is enforcing.
This divergence is not just a rounding footnote. Invariant drift creates a persistent, exploitable gap between what the AMM believes about its own state and what is actually true. Sophisticated attackers — and in some cases just careless users — can widen this gap deliberately, then extract that mismatch as profit. The victims are always the liquidity providers, who find their positions worth less than the invariant guarantees should allow. The attackers are often invisible: they operate entirely within the expected call graph of the protocol, never touching an access-controlled function.
For auditors, this is one of the hardest bug classes to catch manually. No single line of code is obviously wrong. The vulnerability is emergent — it lives in the interaction between fee accounting, reserve tracking, and the invariant check that is supposed to protect LPs. Fuzz-based invariant testing with Foundry is the closest thing the industry has to a systematic detector. This piece teaches you the threat model, the attack anatomy, and how to write harnesses that find drift before deployment.
TL;DR
| Concept | One-liner |
|---|---|
| Invariant drift | Real x * y diverges from stored k due to fees, donations, and rounding |
| Donation attack | Direct transfer() to pool inflates reserves without updating k correctly |
| Fee accumulation | Protocol/LP fees change balances without proportional k adjustment |
| Rounding errors | EVM integer division creates tiny asymmetries that compound over many swaps |
| Audit technique | Write Foundry invariant harnesses that assert k monotonically non-decreasing |
| Primary victims | Liquidity providers via diluted position value |
Foundations & Theory
The pure constant-product formula says: after every swap, x' * y' = x * y. No value enters or leaves; the curve is conserved. The moment you add a fee — even a tiny one — this breaks down by design. In Uniswap V2, the 0.3% swap fee means that (1 - 0.003) of the input amount moves the curve, while 0.003 stays in the pool as LP compensation. This intentionally causes k to grow with every swap. The invariant is not k = constant; it is k_after >= k_before. Uniswap V2 enforces this by computing an "adjusted balance" (balance * 1000 - fee * 3) and checking that the product of adjusted balances is at least k_before * 1000000. This is the _update + swap invariant check.
The design works correctly when the only way to change reserves is through the official swap, mint, and burn entrypoints. But the EVM doesn't enforce that. Any address can call token.transfer(pool_address, amount) and increase the pool's token.balanceOf(pool) without calling any pool function. The pool only learns about this when someone calls sync() or skim(), or when the next swap reads balanceOf and discovers a discrepancy versus the stored reserve variables.
This is the root of invariant drift: there are multiple sources of truth for a pool's token balances, and they can disagree.
Where It Fits in the Workflow
Invariant drift analysis should begin during the architecture review phase, not after deployment. Once you understand the fee model and reserve-update pattern, you can immediately flag whether direct-transfer donations are handled safely. Fuzz harnesses written during the audit double as regression tests the protocol team can carry forward.
Key Concepts in Depth
1. Fee Accumulation as Controlled Drift
In Uniswap V2, LP fees grow k monotonically. This is expected behavior — LPs earn value. But forks sometimes introduce protocol fees (a share of the LP fee redirected to a treasury) that are not immediately extracted. Instead, they sit in the pool as token balances, included in reserves, until a privileged collectProtocolFee() call extracts them. This creates a class of bugs where the invariant check passes (because the fee tokens make k look larger), but the implied price is wrong because the fee balance shouldn't be tradeable as liquidity.
Worse: if the fee collection function has a rounding error and extracts slightly more than it should, it can drive k below its pre-swap value. Any code path that decreases k is a potential LP drain vector.
2. Donation Attacks: Inflation Without Invariant Update
A donation attack is simple in concept. An attacker sends tokens directly to the pool address, bypassing the router. The pool's balanceOf increases, but its stored reserve variable does not. The next call to sync() updates the reserve to match balanceOf, and the pool recalculates k. Suddenly, k has jumped — but no LP shares were minted to account for the injected value. The attacker has donated value to existing LPs.
Why would someone do this intentionally? Because they can engineer a scenario where they are the only LP at the moment of donation, then withdraw their position and capture the donated value. This is the classic "LP share inflation" attack pattern, related to the ERC-4626 vault inflation attack. The steps are:
The Uniswap V2 codebase addresses this partially with a MINIMUM_LIQUIDITY lock of 1000 shares burned at genesis — making the "sole LP" precondition harder to achieve — but forks that remove or reduce this minimum are immediately vulnerable.
3. Rounding Error Accumulation
EVM arithmetic is integer-only. Every division truncates toward zero. In a high-frequency pool, this means every swap can leave a residue of 1 wei that is neither correctly attributed to the LP nor correctly excluded from k. Over millions of swaps, these errors compound.
More critically, rounding in the liquidity mint formula can cause shares to be issued that are worth infinitesimally more than the deposited tokens, or infinitesimally less. Audit-relevant rounding bugs typically appear in one of three places:
liquidity = min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1)— theminensures the more conservative share count, but the discarded value stays in the pool, slightly enriching existing LPs.- The fee-adjusted invariant check:
balance0Adjusted * balance1Adjusted >= kLast * 1e6— ifkLastis not updated atomically with the swap, a reentrancy or a multi-block drift can cause the check to pass when it should not. skim()vssync()asymmetry:skim()transfers out the excess, whilesync()writes the higher balance in. A pool that allows both, called in sequence by an attacker, can be used to manipulate which "truth" the pool accepts.
4. Multi-Step Exploitation: Chaining Drift into Value Extraction
Isolated drift is often just a few wei. Real exploits chain multiple drift conditions across blocks or within a single transaction to amplify the gap. A typical multi-step scenario:
- Setup: Attacker flashloans a large token position.
- Inflate: Attacker calls
sync()to write a donation into reserves, spikingk. - Swap: Attacker executes a swap at the now-incorrect price, which the invariant check passes because the inflated
kcreates more apparent liquidity. - Deflate: Attacker calls
skim()or burns LP shares to recover donated tokens. - Repay: Flashloan repaid; net profit from the price discrepancy.
The key insight for auditors: each step is individually valid. No single call violates access control. The exploit is the sequence. This is why manual code review alone is insufficient — you need to test sequences, not individual functions.
5. Writing Foundry Invariant Harnesses
Foundry's invariant test framework repeatedly calls arbitrary sequences of your handler functions, then asserts global properties. For AMM drift, the core invariant is:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/UniV2Pair.sol";
contract InvariantAMMDrift is Test {
UniV2Pair public pair;
MockERC20 public token0;
MockERC20 public token1;
uint256 public kLast;
function setUp() public {
token0 = new MockERC20("T0", "T0", 18);
token1 = new MockERC20("T1", "T1", 18);
pair = new UniV2Pair(address(token0), address(token1));
// Seed initial liquidity
token0.mint(address(pair), 1e18);
token1.mint(address(pair), 1e18);
pair.mint(address(this));
(uint112 r0, uint112 r1,) = pair.getReserves();
kLast = uint256(r0) * uint256(r1);
}
// Handler: simulate direct donation
function donateToken0(uint96 amount) public {
token0.mint(address(pair), amount);
// Do NOT call sync() — leave as latent drift
}
// Handler: execute a swap
function swap(bool zeroForOne, uint96 amountIn) public {
amountIn = uint96(bound(amountIn, 1, 1e17));
(uint112 r0, uint112 r1,) = pair.getReserves();
if (zeroForOne) {
uint256 amountOut = (uint256(amountIn) * 997 * r1)
/ (uint256(r0) * 1000 + uint256(amountIn) * 997);
token0.mint(address(pair), amountIn);
pair.swap(0, amountOut, address(this), "");
}
// mirror for oneForZero...
}
// THE INVARIANT: k must never decrease
function invariant_kNeverDecreases() public {
(uint112 r0, uint112 r1,) = pair.getReserves();
uint256 kNow = uint256(r0) * uint256(r1);
assertGe(kNow, kLast, "k decreased: invariant drift detected");
kLast = kNow;
}
// SECONDARY: reserves must never exceed balanceOf
function invariant_reservesSolvent() public {
(uint112 r0, uint112 r1,) = pair.getReserves();
assertLe(r0, token0.balanceOf(address(pair)), "reserve0 exceeds balance");
assertLe(r1, token1.balanceOf(address(pair)), "reserve1 exceeds balance");
}
}
Run with: forge test --match-contract InvariantAMMDrift --runs 10000 --depth 50
The --depth 50 flag tells Foundry to generate sequences of up to 50 handler calls before checking invariants. This is where chained drift scenarios surface. If the invariant fails, Foundry's shrinking algorithm will produce the minimal sequence that reproduces the failure — which is often the exact PoC you need for your report.
⚠️ Critical audit note: also assert that balanceOf >= reserve (not just equality), and separately test sync() + skim() sequencing as explicit handler functions. Many forks have the gap only in specific call orderings.
Alternatives & Comparison
| Approach | Strengths | Weaknesses | Best For |
|---|---|---|---|
| Foundry invariant fuzzing | Automated sequence generation, shrinking, fast iteration | Requires harness writing, bounded search space | Pre-deployment audits, CI regression |
| Echidna | Property-based, corpus-guided, Slither integration | Slower setup, steeper learning curve | Long-running audit campaigns |
| Manual math review | Catches structural fee accounting errors quickly | Cannot find emergent multi-step sequences | First-pass architecture review |
| Formal verification (Certora) | Provably exhaustive for specified properties | Expensive, spec-writing requires expertise | High-value protocol deployments |
| Fork + mainnet simulation (Hardhat/Anvil) | Real token contracts, real price feeds | Slow, non-deterministic, hard to assert properties | Validating known PoC sequences |
For most AMM audits, the right answer is manual review to identify candidate drift vectors, then Foundry invariant testing to confirm and characterize them. Formal verification is worth the investment for protocols managing >$100M TVL.
Further Reading & References
- Uniswap V2 Core Whitepaper — original invariant design rationale and fee math
- Trail of Bits: Properties for ERC-4626 Vaults — donation/inflation attack patterns directly applicable to AMM share math
- Paradigm: Uniswap V3 Core Audit by ABDK — rounding analysis in concentrated liquidity
- Foundry Book: Invariant Testing — official reference for handler setup,
targetContract, and run configuration - Dedaub: Invariant Violations in DeFi — taxonomy of real-world invariant breaks with on-chain evidence
- Certora: Formal Verification of Uniswap V2 — how formal methods model AMM invariants
- SushiSwap MISO Incident Post-Mortem — real example of reserve manipulation via direct transfer in a related primitive
- Spearbit: AMM Audit Checklist — community checklist with invariant-check bypass entries
Found this article interesting? Follow me on X and LinkedIn.