Back to articles
Intermediate

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.

@0xrafasecFebruary 18, 2026decentralized_systems_security

Available in Português

Share:

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.

DO
  • 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
DO NOT
  • 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

ConceptOne-liner
Invariant driftReal x * y diverges from stored k due to fees, donations, and rounding
Donation attackDirect transfer() to pool inflates reserves without updating k correctly
Fee accumulationProtocol/LP fees change balances without proportional k adjustment
Rounding errorsEVM integer division creates tiny asymmetries that compound over many swaps
Audit techniqueWrite Foundry invariant harnesses that assert k monotonically non-decreasing
Primary victimsLiquidity 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

Loading diagram…

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:

Loading diagram…

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) — the min ensures 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 — if kLast is not updated atomically with the swap, a reentrancy or a multi-block drift can cause the check to pass when it should not.
  • skim() vs sync() asymmetry: skim() transfers out the excess, while sync() 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:

  1. Setup: Attacker flashloans a large token position.
  2. Inflate: Attacker calls sync() to write a donation into reserves, spiking k.
  3. Swap: Attacker executes a swap at the now-incorrect price, which the invariant check passes because the inflated k creates more apparent liquidity.
  4. Deflate: Attacker calls skim() or burns LP shares to recover donated tokens.
  5. 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:

solidity
// 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

ApproachStrengthsWeaknessesBest For
Foundry invariant fuzzingAutomated sequence generation, shrinking, fast iterationRequires harness writing, bounded search spacePre-deployment audits, CI regression
EchidnaProperty-based, corpus-guided, Slither integrationSlower setup, steeper learning curveLong-running audit campaigns
Manual math reviewCatches structural fee accounting errors quicklyCannot find emergent multi-step sequencesFirst-pass architecture review
Formal verification (Certora)Provably exhaustive for specified propertiesExpensive, spec-writing requires expertiseHigh-value protocol deployments
Fork + mainnet simulation (Hardhat/Anvil)Real token contracts, real price feedsSlow, non-deterministic, hard to assert propertiesValidating 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

Found this article interesting? Follow me on X and LinkedIn.