2025-11-24·18 min read

Top 10 DeFi exploits of 2025: the invariants that would have stopped them

Top 10 DeFi Exploits of 2025: The Invariants That Would Have Stopped Them

By alex

Every year, DeFi loses hundreds of millions to bugs that formal properties would catch before deployment. 2025 was no different. I went through the biggest exploits of the year and reverse-engineered the exact invariant that would have prevented each one.

This isn't an abstract exercise. For the top three, I'll give you the actual Solidity invariant code. For the rest, I'll describe the property precisely enough that you can implement it for your own protocol.

Let's count down.

#10: ZKLend price manipulation — $9.5M (January 2025)

What happened: An attacker manipulated a price oracle's calculation by donating tokens directly to the lending pool's reserves, inflating the exchange rate. They then borrowed against the inflated collateral value and walked away with the difference.

Root cause: The protocol used a spot exchange rate derived from pool balances instead of a manipulation-resistant price source. Direct token transfers weren't accounted for in the price calculation.

Dollar amount: ~$9.5M

The invariant that stops it:

/// @notice Exchange rate must not change by more than X% in a single block
function invariant_exchange_rate_bounded_per_block() public view {
    uint256 currentRate = pool.exchangeRate();
    uint256 previousRate = handler.previousBlockExchangeRate();

    if (previousRate == 0) return;

    uint256 deviation;
    if (currentRate > previousRate) {
        deviation = ((currentRate - previousRate) * 10000)
            / previousRate;
    } else {
        deviation = ((previousRate - currentRate) * 10000)
            / previousRate;
    }

    // Exchange rate can't move more than 1% per block
    assertLe(
        deviation,
        100,
        "Exchange rate moved too much in one block"
    );
}

Any protocol that derives prices from pool balances needs this property. It catches donation attacks, flash loan manipulation, and other single-block price distortions. See oracle manipulation for more background.

#9: Abracadabra / magic internet money — $13M (March 2025)

What happened: An attacker exploited a rounding error in the cauldron's liquidation mechanism. By carefully constructing borrow positions at specific sizes, the attacker triggered liquidations that returned more collateral value than the debt they repaid.

Root cause: Integer division in the liquidation math rounded in the attacker's favor when dealing with small position sizes. The liquidation bonus calculation didn't properly account for precision loss.

Dollar amount: ~$13M

The invariant that stops it: After any liquidation, the protocol must have more value than before (from the liquidation penalty). If a liquidation makes the protocol poorer, the math is wrong.

/// @notice Liquidation must always improve protocol solvency
function invariant_liquidation_improves_health() public view {
    if (handler.lastActionWasLiquidation()) {
        uint256 protocolValueBefore = handler.protocolValueBeforeLiquidation();
        uint256 protocolValueAfter = _calculateProtocolValue();

        assertGe(
            protocolValueAfter,
            protocolValueBefore,
            "Liquidation decreased protocol value"
        );
    }
}

Check out rounding errors in DeFi, this bug pattern shows up over and over.

#8: sIR.trading vault drain, $355K (March 2025)

What happened: The protocol's vault contract had a callback in the withdrawal flow that could be re-entered. An attacker used a malicious token callback to re-enter during withdrawal, withdrawing their share multiple times before the balance update.

Root cause: Classic reentrancy via token callback. The contract updated internal state after the external call.

Dollar amount: ~$355K

The invariant that stops it:

/// @notice Total shares must always equal sum of individual shares
function invariant_share_accounting() public view {
    uint256 sumShares;
    for (uint256 i = 0; i < handler.userCount(); i++) {
        sumShares += vault.balanceOf(handler.users(i));
    }
    assertEq(
        sumShares,
        vault.totalSupply(),
        "Share accounting mismatch -- possible reentrancy"
    );
}

/// @notice Vault assets must cover all outstanding shares
function invariant_vault_solvency() public view {
    if (vault.totalSupply() == 0) return;

    uint256 totalAssets = token.balanceOf(address(vault));
    uint256 totalShareValue = vault.convertToAssets(vault.totalSupply());

    assertGe(
        totalAssets,
        totalShareValue,
        "Vault insolvent -- assets don't cover shares"
    );
}

The share accounting invariant breaks the moment reentrancy causes a double-withdrawal. For more on vault security, see how to fuzz ERC-4626 vaults.

#7: Bybit / Safe multisig UI, $1.5B (February 2025)

What happened: Attackers compromised a developer machine that had access to the Safe multisig UI infrastructure. They injected malicious JavaScript that modified the transaction payload presented to Bybit signers. The signers approved what they thought was a routine transaction, but the actual on-chain payload replaced the Safe implementation with an attacker-controlled contract.

Root cause: Supply chain attack on the frontend. The signing interface showed one transaction while broadcasting another. The multisig contract itself worked correctly, the humans were tricked.

Dollar amount: ~$1.5B

The invariant that stops it: This is a tough one because the exploit happened off-chain. But an on-chain invariant can still catch the aftermath:

A property verifying that the Safe's implementation address only changes to a pre-approved whitelist of implementations would have blocked the malicious upgrade, even after the signers were tricked into signing it.

This falls into the proxy pattern category. Whitelisting valid implementations on-chain turns a UI compromise into a failed transaction.

#6: KiloEx oracle manipulation, $7.5M (April 2025)

What happened: The perpetuals protocol's price oracle had a flaw in its validation logic. An attacker opened positions using manipulated prices, then closed them at the real price, pocketing the difference. The oracle's "freshness" check used a window that was too wide.

Root cause: The oracle staleness threshold was 30 minutes, long enough for an attacker to submit stale prices that diverged from the market. The protocol also lacked cross-reference between its oracle and any secondary price source.

Dollar amount: ~$7.5M

The invariant that stops it:

/// @notice Oracle staleness must be within acceptable bounds
function invariant_oracle_freshness() public view {
    (, , , uint256 updatedAt, ) = oracle.latestRoundData();
    assertLe(
        block.timestamp - updatedAt,
        MAX_STALENESS,
        "Stale oracle price accepted"
    );
}

Two minutes of staleness is acceptable. Thirty is not. See oracle integration testing for proper thresholds.

#5: Euler finance V2 accounting bug, $200M (Ongoing vulnerability, disclosed 2025)

What happened: A rounding issue in Euler V2's interest accrual allowed attackers to slowly extract value from the protocol by depositing and withdrawing in patterns that exploited the precision loss. The bug was disclosed responsibly and patched before full exploitation, but the potential loss was estimated at $200M.

Root cause: Interest accrual calculations used block-by-block compounding with integer math, and the rounding consistently favored the borrower. Over thousands of blocks, small rounding errors accumulated into real value extraction.

Dollar amount: ~$200M (potential, patched)

The invariant that stops it:

/// @notice Protocol total value must be monotonically non-decreasing
/// (ignoring legitimate losses like bad debt)
function invariant_protocol_value_monotonic() public view {
    uint256 currentValue = protocol.totalAssets()
        + protocol.totalInterestOwed();
    uint256 previousValue = handler.previousProtocolValue();

    // Value should only increase (from interest) or stay same
    assertGe(
        currentValue + handler.acknowledgedBadDebt(),
        previousValue,
        "Protocol lost value without bad debt event"
    );
}

#4: Hyperliquid bridge governance, $12M (March 2025)

What happened: The JELLY token market was manipulated through a series of coordinated trades across Hyperliquid's DEX. Traders opened massive short positions, then bought the token on external markets to inflate the price, forcing Hyperliquid's vault to absorb the losing positions at inflated prices.

Root cause: The protocol's risk management didn't account for the cross-market manipulation vector. Position limits were checked per-account but not per-asset, and the vault's exposure to a single asset wasn't capped.

Dollar amount: ~$12M

The invariant that stops it: Vault exposure to any single asset must be bounded:

/// @notice Vault exposure to any single asset must stay below limit
function invariant_single_asset_exposure_bounded() public view {
    for (uint256 i = 0; i < handler.assetCount(); i++) {
        address asset = handler.assets(i);
        uint256 exposure = vault.exposureTo(asset);
        uint256 totalVaultValue = vault.totalValue();

        // No single asset should be more than 20% of vault
        assertLe(
            exposure * 100 / totalVaultValue,
            20,
            "Single asset exposure exceeds limit"
        );
    }
}

#3: Infini stablecoin exploit, $49M (February 2025)

What happened: A former developer retained admin privileges to the Infini stablecoin protocol. They used these retained credentials to drain funds from the protocol's vault. The access hadn't been revoked after the developer left the team.

Root cause: Access control failure. The protocol didn't have a process for revoking access when team members departed, and there was no on-chain enforcement of access review.

Dollar amount: ~$49M

The invariant that stops it:

/// @notice Only current admin set can perform privileged operations
function invariant_access_control_current() public view {
    for (uint256 i = 0; i < handler.privilegedActionCount(); i++) {
        PrivilegedAction memory action = handler.getAction(i);
        if (action.succeeded) {
            assertTrue(
                protocol.hasRole(
                    protocol.ADMIN_ROLE(),
                    action.caller
                ),
                "Privileged action by non-admin"
            );
        }
    }
}

/// @notice Admin count must not exceed maximum
function invariant_admin_count_bounded() public view {
    uint256 adminCount = protocol.getRoleMemberCount(
        protocol.ADMIN_ROLE()
    );
    assertLe(
        adminCount,
        MAX_ADMINS,
        "Too many admins -- review access list"
    );
}

Time-bounded access (where admin roles automatically expire and must be explicitly renewed) would also have prevented this.

#2: 1inch resolver exploit, $5M (March 2025)

What happened: The 1inch Fusion swap resolver had a vulnerability in its validation of swap parameters. An attacker crafted malicious swap data that passed the resolver's checks but executed differently than expected, allowing them to extract tokens from the resolver's balance.

Root cause: The resolver's order validation logic didn't fully verify all swap parameters. Input validation was incomplete, certain fields were trusted without proper bounds checking.

Dollar amount: ~$5M

The invariant that stops it:

/// @notice Resolver balance must never decrease except by expected fees
function invariant_resolver_balance_protected() public view {
    uint256 currentBalance = token.balanceOf(address(resolver));
    uint256 previousBalance = handler.previousResolverBalance();
    uint256 feesCollected = handler.feesCollectedSinceLastCheck();

    // Balance should increase from fees, never decrease unexpectedly
    assertGe(
        currentBalance + feesCollected,
        previousBalance,
        "Resolver lost funds unexpectedly"
    );
}

#1: Radiant capital (Continued from 2024), $58M

What happened: Radiant Capital suffered from a compromised multi-sig where attackers gained control of enough private keys to authorize malicious transactions. The attackers upgraded the lending pool contracts to malicious versions that drained user funds.

Root cause: Insufficient operational security around multi-sig key management, combined with the ability to upgrade critical contracts with a simple multi-sig threshold. No timelock, no on-chain delay.

Dollar amount: ~$58M (across multiple chains)

The invariant that stops it:

/// @notice Critical upgrades must go through timelock
function invariant_upgrade_requires_timelock() public view {
    if (handler.upgradeAttempts() > 0) {
        for (uint256 i = 0; i < handler.upgradeAttempts(); i++) {
            UpgradeAttempt memory attempt = handler.getAttempt(i);
            if (attempt.succeeded) {
                assertGt(
                    attempt.timestamp - attempt.queuedAt,
                    MINIMUM_TIMELOCK_DELAY,
                    "Upgrade executed without timelock delay"
                );
            }
        }
    }
}

A 48-hour timelock on upgrades would've given the community and monitoring systems time to detect and react to the malicious upgrade. See governance security for proper timelock patterns.

The pattern across all 10 exploits

Let me categorize what we've seen:

CategoryExploitsTotal Lost
Oracle/Price Manipulation#10, #6, #4~$29M
Access Control#3, #1~$107M
Accounting/Rounding#9, #5, #8~$213.4M
Input Validation#2~$5M
Supply Chain/UI#7~$1.5B

Three themes stand out:

  1. Accounting bugs are the most expensive. Rounding errors, precision loss, and share accounting bugs caused the most aggregate damage (excluding the Bybit supply chain attack, which is in its own category). Every protocol needs value conservation invariants.

  2. Access control failures are preventable. Both the Infini and Radiant exploits were about who could perform actions, not bugs in the action logic itself. Timelocks and access monitoring catch these.

  3. Oracle manipulation keeps coming back. Despite years of known attack patterns, protocols still ship with staleness windows that are too wide and missing cross-reference checks.

What you should do

Take these properties and adapt them to your protocol. You don't need all of them on day one. Start with:

  1. Value conservation, total protocol value doesn't decrease without explanation
  2. Share accounting, total shares = sum of individual shares
  3. Access control, only authorized addresses can perform privileged operations
  4. Oracle sanity, prices are fresh, bounded, and consistent across sources

These four properties would've caught 8 out of 10 exploits on this list.

Invariant testing isn't a nice-to-have anymore. It's the difference between finding these bugs in your test suite and finding them on your post-mortem blog. If you haven't started yet, how to write your first invariant test takes you from zero to your first working property in about 30 minutes.

For the full picture on smart contract security testing, fuzzing is your best tool for property verification. It doesn't prove correctness, but it finds incorrectness fast.


Want to make sure your protocol doesn't end up on next year's list? Request an Audit or Try Recon Pro to start testing with invariants today.

Related Posts

Related Glossary Terms

Don't be next — get invariant testing