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:
- Deposited collateral and borrowed to maximum leverage
- Called
liquidate()on their own position in the same transaction - 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!
}
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
);
}
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");
}
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:
- Borrowed a small amount
- Triggered a discount settlement (which calculated "interest" = current balance - last snapshot)
- Borrowed more (increasing stored balance)
- Triggered settlement again — the protocol interpreted the new borrow as accrued interest
- 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];
}
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;
}
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");
}
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:
- The
totalAssets()denominator was artificially low when calculating share price - Depositors received an inflated number of shares
- On
withdraw(), the harvest was triggered, increasing total assets - The inflated shares were now redeemable at the higher, post-harvest share price
The attacker sandwiched a large reward accrual:
- Deposited right before a reward compound (getting cheap shares)
- Called
withdraw()which triggeredharvest()→ total assets increased - 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);
}
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;
}
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
Self-interaction guards: Can a user be both sides of a liquidation, trade, or transfer? Test
msg.sender == counterpartyon every two-party function.Storage operation ordering: When two writes target the same slot, do they compose correctly? Use direct storage mutations, not memory caches that get flushed.
Interest derivation: Is interest calculated from a time-weighted index, or from balance deltas? Balance deltas include principal changes — they are not interest.
Share pricing freshness: Before minting or burning shares, is the total-assets denominator fully settled? Harvest, accrue, and compound before pricing.
-
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
Foundry invariant tests: Encode these as stateful invariant tests with a multi-actor handler. Run with
forge test --mt invariant -vvvvand 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()againstborrower.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>,
// ...
}
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)