DEV Community

ohmygod
ohmygod

Posted on

Three Accounting Bugs That Drained $107K from DeFi Lending Protocols in One Week

Three Accounting Bugs That Drained $107K from DeFi Lending Protocols in One Week

Between March 9 and March 15, 2026, three DeFi lending protocols — Alkemi, Planet Finance, and Goose Finance — were exploited through accounting logic errors. Combined losses: roughly $107,000. The dollar amounts are modest by DeFi standards. The patterns they reveal are not.

All three exploits share a common ancestor: the state that gets read is not the state that gets written. Whether it's a liquidation collateral deduction that gets overwritten, a borrow balance increase misinterpreted as interest, or shares minted before rewards settle — these are all manifestations of the same fundamental class of bug.

This article dissects each exploit, extracts the underlying anti-pattern, and provides concrete Solidity patterns to prevent them. If you're building or auditing a lending protocol, these three patterns deserve a place in your invariant test suite.


Exploit 1: Alkemi — The Self-Liquidation Memory Variable Overwrite ($89K)

What Happened (March 10, 2026)

Alkemi's liquidation function allowed a borrower to liquidate their own position within the same transaction. The attacker:

  1. Deposited collateral and borrowed to maximum leverage
  2. Called liquidate() on their own position in the same transaction
  3. Received the liquidation reward (discounted collateral)

The critical bug: Alkemi's liquidation logic used temporary memory variables to calculate the borrower's collateral deduction and the liquidator's reward separately. When the borrower and liquidator are the same address, the final sstore for the reward overwrote the earlier collateral deduction.

Result: the attacker received liquidation rewards without losing collateral.

The Anti-Pattern: Memory-First, Storage-Last with Shared State

// ❌ VULNERABLE: Memory variables that collide when borrower == liquidator
function liquidate(address borrower, uint256 repayAmount) external {
    uint256 borrowerCollateral = collateralBalance[borrower];  // read
    uint256 liquidatorCollateral = collateralBalance[msg.sender];  // read (same slot!)

    uint256 seizeAmount = calculateSeize(repayAmount);

    borrowerCollateral -= seizeAmount;   // computed in memory
    liquidatorCollateral += seizeAmount; // computed in memory

    collateralBalance[borrower] = borrowerCollateral;       // write
    collateralBalance[msg.sender] = liquidatorCollateral;   // OVERWRITES if same address!
}
Enter fullscreen mode Exit fullscreen mode

When borrower == msg.sender, the second write clobbers the first. The net effect: collateral only goes up.

The Fix: Direct Storage Operations + Self-Liquidation Guard

// ✅ SAFE: Direct storage mutations + explicit self-liquidation check
function liquidate(address borrower, uint256 repayAmount) external {
    require(borrower != msg.sender, "SELF_LIQUIDATION_BLOCKED");

    uint256 seizeAmount = calculateSeize(repayAmount);

    // Operate directly on storage — no memory intermediaries
    collateralBalance[borrower] -= seizeAmount;
    collateralBalance[msg.sender] += seizeAmount;

    debtBalance[borrower] -= repayAmount;

    // Invariant: total collateral unchanged
    assert(
        collateralBalance[borrower] + collateralBalance[msg.sender] == 
        _preBorrowerCollateral + _preLiquidatorCollateral
    );
}
Enter fullscreen mode Exit fullscreen mode

Key principle: When two state updates can reference the same storage slot, either (a) block the collision case, or (b) operate directly on storage so each mutation sees the result of the prior one.

Foundry Invariant Test

function invariant_liquidationConservesCollateral() public {
    uint256 totalCollateral;
    for (uint256 i = 0; i < actors.length; i++) {
        totalCollateral += lendingPool.collateralBalance(actors[i]);
    }
    assertEq(totalCollateral, ghost_totalCollateral, 
        "Liquidation must not create or destroy collateral");
}
Enter fullscreen mode Exit fullscreen mode

Exploit 2: Planet Finance — Borrow Balance Increases Misread as Interest ($10K)

What Happened (March 11, 2026)

Planet Finance, a BNB Chain lending protocol, had a discount settlement mechanism that calculated accrued interest by looking at changes in a borrower's stored borrow balance. The problem: any increase in the borrow balance was treated as interest, including new borrows.

The attacker:

  1. Borrowed a small amount
  2. Triggered a discount settlement (which calculated "interest" = current balance - last snapshot)
  3. Borrowed more (increasing stored balance)
  4. Triggered settlement again — the protocol interpreted the new borrow as accrued interest
  5. Repeated, effectively reducing their recorded debt with each cycle

The Anti-Pattern: Conflating Balance Deltas with Interest Accrual

// ❌ VULNERABLE: Any balance increase is treated as interest
function settleDiscount(address borrower) internal {
    uint256 currentDebt = borrowBalance[borrower];
    uint256 lastSnapshot = debtSnapshot[borrower];

    // BUG: This delta includes new borrows, not just interest!
    uint256 accruedInterest = currentDebt - lastSnapshot;

    uint256 discount = accruedInterest * discountRate / PRECISION;
    borrowBalance[borrower] -= discount;
    debtSnapshot[borrower] = borrowBalance[borrower];
}
Enter fullscreen mode Exit fullscreen mode

The Fix: Separate Interest Index from Principal Changes

// ✅ SAFE: Track interest via index, not balance deltas
struct BorrowPosition {
    uint256 principal;
    uint256 interestIndex;  // snapshot of global index at last interaction
}

mapping(address => BorrowPosition) public borrows;
uint256 public globalBorrowIndex = 1e18;  // compound-style index

function accruedDebt(address borrower) public view returns (uint256) {
    BorrowPosition memory pos = borrows[borrower];
    return pos.principal * globalBorrowIndex / pos.interestIndex;
}

function borrow(uint256 amount) external {
    accrueInterest();  // update globalBorrowIndex first

    BorrowPosition storage pos = borrows[msg.sender];

    // Settle existing interest into principal
    pos.principal = accruedDebt(msg.sender);
    pos.interestIndex = globalBorrowIndex;

    // Now add new principal — cleanly separated from interest
    pos.principal += amount;

    // Transfer tokens...
}

function settleDiscount(address borrower) internal {
    accrueInterest();

    BorrowPosition storage pos = borrows[borrower];
    uint256 totalDebt = accruedDebt(borrower);

    // Interest = totalDebt - principal (only actual interest, not new borrows)
    uint256 interest = totalDebt - pos.principal;
    uint256 discount = interest * discountRate / PRECISION;

    pos.principal = totalDebt - discount;
    pos.interestIndex = globalBorrowIndex;
}
Enter fullscreen mode Exit fullscreen mode

Key principle: Never derive interest from balance deltas. Use Compound-style interest indices that are immune to principal changes.

Foundry Test

function test_borrowDoesNotGeneratePhantomInterest() public {
    vm.startPrank(alice);
    pool.deposit(100e18);
    pool.borrow(10e18);

    uint256 debtBefore = pool.accruedDebt(alice);

    // Borrow more — no time passes, so no interest accrues
    pool.borrow(5e18);
    pool.settleDiscount(alice);

    uint256 debtAfter = pool.accruedDebt(alice);

    // Debt should increase by exactly the new borrow, minus zero discount
    assertEq(debtAfter, debtBefore + 5e18, 
        "New borrow must not generate phantom interest");
}
Enter fullscreen mode Exit fullscreen mode

Exploit 3: Goose Finance — Shares Minted Before Rewards Settle ($8K)

What Happened (March 15, 2026)

Goose Finance, a BNB Chain yield-farming protocol, had a deposit() function in its StrategyGooseEgg vault that minted shares before harvesting and settling pending rewards. This meant:

  1. The totalAssets() denominator was artificially low when calculating share price
  2. Depositors received an inflated number of shares
  3. On withdraw(), the harvest was triggered, increasing total assets
  4. The inflated shares were now redeemable at the higher, post-harvest share price

The attacker sandwiched a large reward accrual:

  1. Deposited right before a reward compound (getting cheap shares)
  2. Called withdraw() which triggered harvest() → total assets increased
  3. Redeemed shares at the now-higher price

The Anti-Pattern: Stale Denominator During Share Calculation

// ❌ VULNERABLE: Shares minted against stale totalAssets
function deposit(uint256 amount) external {
    uint256 shares = (amount * totalShares) / totalAssets();  // stale!

    // Mint shares first...
    _mint(msg.sender, shares);

    // ...then harvest (which increases totalAssets)
    _harvest();  // TOO LATE — shares already priced against old total

    asset.transferFrom(msg.sender, address(this), amount);
}
Enter fullscreen mode Exit fullscreen mode

The Fix: Harvest-Before-Mint + Deposit/Withdrawal Queues

// ✅ SAFE: Always settle rewards before pricing shares
function deposit(uint256 amount) external nonReentrant {
    // Step 1: Harvest first — totalAssets() is now current
    _harvest();

    // Step 2: Calculate shares against up-to-date total
    uint256 totalAssetsNow = totalAssets();
    uint256 shares;
    if (totalShares == 0) {
        shares = amount;  // first deposit
    } else {
        shares = (amount * totalShares) / totalAssetsNow;
    }

    // Step 3: Transfer, then mint (CEI pattern)
    asset.transferFrom(msg.sender, address(this), amount);
    _mint(msg.sender, shares);

    // Invariant: share price didn't move
    assert(_sharePriceWithin(totalAssetsNow, totalShares, 1)); // 1 wei tolerance
}

function _sharePriceWithin(
    uint256 prevAssets, uint256 prevShares, uint256 tolerance
) internal view returns (bool) {
    if (prevShares == 0) return true;
    uint256 priceBefore = (prevAssets * 1e18) / prevShares;
    uint256 priceAfter = (totalAssets() * 1e18) / totalShares;
    return priceAfter >= priceBefore - tolerance && 
           priceAfter <= priceBefore + tolerance;
}
Enter fullscreen mode Exit fullscreen mode

Key principle: The share price must reflect the current state of all assets at the moment shares are minted or burned. Harvest before mint. Always.


The Unified Pattern: Accounting State Machines

All three exploits violate the same meta-principle: accounting operations must be atomic with respect to the state they price against. When they're not, you get:

Bug Class What Gets Stale Example
Memory variable overwrite Collateral balance Alkemi
Delta conflation Interest vs. principal Planet Finance
Stale denominator Total assets before harvest Goose Finance

A General Defense Checklist for Lending Protocol Auditors

  1. Self-interaction guards: Can a user be both sides of a liquidation, trade, or transfer? Test msg.sender == counterparty on every two-party function.

  2. Storage operation ordering: When two writes target the same slot, do they compose correctly? Use direct storage mutations, not memory caches that get flushed.

  3. Interest derivation: Is interest calculated from a time-weighted index, or from balance deltas? Balance deltas include principal changes — they are not interest.

  4. Share pricing freshness: Before minting or burning shares, is the total-assets denominator fully settled? Harvest, accrue, and compound before pricing.

  5. Same-transaction invariants: After every deposit, borrow, liquidation, and withdrawal, assert:

    • Total supply of shares × share price ≈ total assets (within rounding)
    • Total collateral is conserved across liquidations
    • Total debt increases only from interest accrual and new borrows
    • No user's debt decreased without a corresponding repayment
  6. Foundry invariant tests: Encode these as stateful invariant tests with a multi-actor handler. Run with forge test --mt invariant -vvvv and at least 10,000 runs.


Solana Parallels

These patterns aren't EVM-exclusive. Solana lending protocols face equivalent risks:

  • Self-liquidation: Without explicit signer checks comparing liquidator.key() against borrower.key(), a CPI call can liquidate the caller's own position
  • Interest conflation: Solana programs using stored balance snapshots (instead of slot-based interest indices) are vulnerable to the same phantom interest bug
  • Stale share pricing: Solana vault programs that calculate shares before cranking reward distribution have the same harvest-before-mint vulnerability
// Anchor: Self-liquidation guard
#[derive(Accounts)]
pub struct Liquidate<'info> {
    #[account(mut)]
    pub liquidator: Signer<'info>,
    /// CHECK: Validated in handler
    #[account(
        mut,
        constraint = borrower.key() != liquidator.key() 
            @ ErrorCode::SelfLiquidation
    )]
    pub borrower: AccountInfo<'info>,
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Takeaway

The March 9–15 exploit week was a masterclass in accounting bugs. None of these exploits required flash loans, oracle manipulation, or sophisticated MEV strategies. They required only that the attacker read the code and noticed that the numbers didn't add up.

The defense is equally straightforward: make the numbers add up, and prove it with invariant tests. Every deposit, borrow, liquidation, and withdrawal should leave the protocol's books balanced — not approximately, not eventually, but immediately and provably.

Your lending protocol's accounting logic is either correct by construction and verified by invariant tests, or it's the next $89K headline. There is no middle ground.


This article is part of the DeFi Security Research series. Follow for weekly deep dives into smart contract vulnerabilities, audit techniques, and defense patterns.

References:

Top comments (0)