August 1, 2026
Single-sided deposits dilute paired-staker yield
Some vaults only make money when two sides meet. Deposits land on opposing sides, matched amounts get put to work in an external yield source, and the leftover on the heavier side sits idle. The idle part earns nothing. The accounting, though, often forgets that distinction and pays yield to idle shares anyway.
Here is the shape that matters.
contract Vault {
uint256 public totalSharesA;
uint256 public totalSharesB;
uint256 public principal; // matched notional put to work
uint256 public yieldAccrued;
mapping(address => uint256) public sharesA;
mapping(address => uint256) public sharesB;
function depositA(uint256 amount) external {
sharesA[msg.sender] += amount;
totalSharesA += amount;
_rematch();
}
function _rematch() internal {
uint256 matched = totalSharesA < totalSharesB ? totalSharesA : totalSharesB;
principal = matched; // only the overlap earns
}
function accrue(uint256 amount) external {
yieldAccrued += amount; // paid by the external source on `principal`
}
function pendingYieldA(address who) external view returns (uint256) {
if (totalSharesA == 0) return 0;
return (yieldAccrued / 2) * sharesA[who] / totalSharesA;
}
}
Yield is created by principal, the matched overlap between side A and side B. Yield is distributed by totalSharesA, the full balance on side A. Those two numbers are equal only while every A share has a B share behind it. The moment they diverge, the payout formula is dividing a fixed pot by a denominator that has nothing to do with what produced the pot.
A worked instance
Alice deposits 100 on side A and 100 on side B. The sides match exactly, so principal is 100 and it earns. The external source pays 100 of yield, split evenly between the sides, so side A holds 50 to distribute. Alice owns all of side A, and pendingYieldA(alice) returns 50 * 100 / 100 = 50. She earned it.
Bob now deposits 100 on side A alone. He adds no B, so _rematch leaves principal at 100. No new capital went to work and no new yield was created. But totalSharesA is now 200. Recompute: Alice gets 50 * 100 / 200 = 25, and Bob gets 50 * 100 / 200 = 25. Bob walks away with 25 of yield he did not fund, taken directly out of Alice's balance. His deposit was pure dilution.
The class
The bug is a denominator that outgrows its numerator. Rewards are minted by one quantity (matched, working principal) and split by a broader one (all shares on a side). Any share that inflates the denominator without contributing to the numerator is a free rider, and every honest staker pays for it pro-rata.
This is the same skeleton as a vault that mints shares before pulling assets, or a rewards pool whose accumulator counts staked tokens that were never actually forwarded to the strategy. The tell is always two sets of books that a code path is allowed to desynchronize: one tracking what earns, one tracking who gets paid. Match them and the bug cannot exist. Let them drift and someone will deposit into the gap.
Why it survives the test suite
The usual tests deposit balanced pairs. Alice-only scenarios keep totalSharesA == totalSharesB == principal, so the formula is exactly right and every assertion is green. The invariant the suite quietly assumes is totalSharesA == principal, and single-sided deposits are precisely the input that breaks it. A property worth writing is that the sum of pendingYield over a side never exceeds that side's cut of yieldAccrued given only matched shares, and that an unmatched deposit cannot lower anyone else's pending balance.
The fix
Pay yield over the shares that actually back principal, not over every share on the side. Track the matched portion per depositor at match time and divide by principal.
mapping(address => uint256) public matchedA; // A shares backed by a B share
function pendingYieldA(address who) external view returns (uint256) {
if (principal == 0) return 0;
return (yieldAccrued / 2) * matchedA[who] / principal;
}
With matchedA capped by matching and the denominator equal to what earns, an unmatched deposit changes nobody's payout. Distribute yield across the capital that produced it, never across the capital that merely showed up.
Daniel Kuppitz · Founder & Principal · Solidity audits at Cimora