A deposit cap is supposed to answer one simple question:
Is this market already full?
In CurrentSui, that answer can become wrong once the protocol has accumulated reserve.
The reason is a double subtraction of cash_reserve.
The protocol’s total_deposit_plus_interest() value already represents depositor backing net of reserve. But deposit_limit_breached() subtracts cash_reserve again before comparing the result with max_deposit_amount.
That makes current utilization look smaller than it really is.
Once positive reserve exists, a market that is already full according to the protocol’s own depositor backing metric can admit another deposit that should have been rejected.
The impact becomes more interesting when deposit liquidity mining is active. An extra deposit accepted through the broken cap receives cTokens, enters the reward share set, and starts receiving campaign emissions.
The PoC demonstrated this with an exact reserve state: a market already at its configured cap accepted an additional deposit equal to 8,000 USDC of reserve, and that new depositor later captured more than 1,000 USDC of rewards that would otherwise have remained with the original lender.
I reported this finding as Medium in the Sherlock CurrentSui contest. It earned $92.24.

The flash loan path used in the PoC is only a deterministic way to create positive reserve. The exploit itself is the ordinary public deposit path.
The cap is supposed to measure total market deposits
CurrentSui defines max_deposit_amount as the maximum amount that can be deposited across the market:
/// max amount this asset can be deposited across the market
max_deposit_amount: u64,
Source: asset.move
The intended invariant is straightforward:
current depositor backing
+
new deposit
<=
max_deposit_amount
If current backing is already equal to the configured cap, a new deposit should fail.
Depositor backing already removes cash_reserve
The important accounting path begins in reserve.move:
public(package) fun cash_plus_borrows_minus_reserves<MarketType>(
self: &Reserve<MarketType>
): Decimal {
self.debt.add_u64(self.cash).sub(self.cash_reserve)
}
The exchange rate uses that value:
public(package) fun exchange_rate<MarketType>(
self: &Reserve<MarketType>
): Decimal {
if (self.total_supply == 0) {
return float::from_quotient(1, 1)
};
let numerator = self.cash_plus_borrows_minus_reserves();
let denominator = float::from(self.total_supply);
numerator.div(denominator)
}
And total_deposit_plus_interest() is calculated from the exchange rate:
public(package) fun total_deposit_plus_interest<MarketType>(
self: &Reserve<MarketType>
): Decimal {
self.exchange_rate().mul_u64(self.total_supply)
}
Source: reserve.move
So the resulting depositor backing already reflects:
debt
+
cash
-
cash_reserve
That reserve subtraction has already happened before the deposit cap check runs.
The deposit gate subtracts the same reserve again
The bug is in deposit_limit_breached():
public(package) fun deposit_limit_breached<MarketType>(
self: &Reserve<MarketType>,
increment: u64,
limit: u64
): bool {
let total_deposit_plus_interest =
self.total_deposit_plus_interest();
total_deposit_plus_interest.ceil()
+ increment
- self.cash_reserve.ceil()
> limit
}
Source: reserve.move
Conceptually, the intended check is:
current depositor backing
+
new deposit
>
cap
But the implementation behaves like:
current depositor backing
+
new deposit
-
cash_reserve
>
cap
Because current depositor backing was already net of reserve, the gate understates utilization by the reserve term again.
In the PoC, the broken check reopens apparent capacity equal to the 8,000 USDC reserve.
The public deposit path trusts the broken result
The market deposit flow directly relies on that helper:
assert!(
!reserve.deposit_limit_breached<MarketType>(
deposit_amount,
asset_config.max_deposit_amount(),
),
error::market_deposit_limit_exceeded(),
);
let ctoken = reserve.mint_ctokens(coin);
Source: market.move
There is no later cap reconciliation.
If deposit_limit_breached() returns false, the deposit is accepted and cTokens are minted.
That is what turns the accounting mistake into a real public state transition.
Positive reserve is a normal protocol state
The PoC uses flash loan fees because they make it easy to construct an exact reserve amount without changing depositor backing or depositor shares.
But the vulnerability is not specific to flash loans.
The protocol can accumulate reserve through ordinary mechanisms, including borrow interest accrual, liquidation revenue, over repayment donated to reserve, and retained flash loan fees.
For example, borrow interest accrual increases cash_reserve here:
let interest_accumulated =
self.debt.mul(simple_interest_factor);
self.debt =
self.debt.add(interest_accumulated);
self.cash_reserve =
self.cash_reserve.add(
reserve_factor.mul(interest_accumulated)
);
Source: reserve.move
So an ordinary user can encounter the vulnerable state after reserve has accumulated naturally.
The attacker does not need to control the mechanism that created the reserve.
Why the flash loan setup does not make the exploit privileged
The test uses the real flash loan fee path because it creates a clean state where:
depositor backing
unchanged
depositor shares
unchanged
protocol reserve
positive
Flash loan borrowing itself requires a permissioned caller, but that permission is only used to build the test state.
The action that exploits the broken cap is the normal public deposit() entry point.
Once positive reserve already exists, the attacker only needs an ordinary position and the ability to deposit.
The accepted deposit immediately affects liquidity mining
After handle_mint() succeeds, the deposit entry point updates the user’s deposit reward share:
let (
ctoken_amount,
total_ctoken_amount
) = market.handle_mint(
obligation_owner_cap.id(),
coin,
now
);
let miner =
market.borrow_liquidity_mining_mut<MarketType>();
miner.update_obligation_reward_manager<
MarketType,
CoinType
>(
get_deposit_reward_type(),
obligation_owner_cap.id(),
total_ctoken_amount,
clock
);
Source: deposit.move
That update changes the reward manager share accounting used to distribute campaign emissions.
So the broken cap does not stop at market capacity.
It changes who participates in reward distribution.
The PoC proves the full chain
The Move PoC uses seven tests, each isolating a different part of the finding:
| Test | What it proves |
|---|---|
cap_fail |
The exploit sized deposit correctly fails when no reserve has created false capacity. |
gate_lies |
After reserve exists, the market is still economically full but deposit_limit_breached() returns false for the extra deposit. |
cap_pass |
The attacker successfully deposits the full reopened amount through the public path. |
reserve_8k |
The setup proves the exact reserve amount and computes the expected reward diversion. |
mine_full |
Without the attacker, the original lender receives the full campaign reward. |
mine_1k |
The accepted extra deposit produces a measurable attacker reward and an equal lender loss. |
live_1k |
The same diversion works after the reward campaign is already active. |
The root cause is demonstrated especially clearly by gate_lies.
Before the attacker deposits, the test proves that depositor backing remains equal to the cap and that the new deposit would push backing above it.
Yet the helper still says the limit is not breached:
assert!(
!r.deposit_limit_breached<MainMarket>(
target,
cap0
),
37
);
The later reward tests then show why that incorrect admission matters economically.
Without the exploit, the lender receives the full campaign. With the extra deposit, the attacker receives more than 1,000 USDC, and the lender’s reduction matches the attacker’s gain.
The live_1k test also starts the campaign before the attacker enters, proving that the attacker can dilute an already active reward program rather than merely joining some future campaign.
The complete test run passed:
Test result: OK.
Total tests: 7; passed: 7; failed: 0
The evidence therefore forms one continuous path:
full market
→ positive reserve
→ utilization undercounted
→ extra public deposit accepted
→ reward shares increased
→ attacker receives campaign rewards
→ original lender loses the same value
Why I reported it as Medium
The impact demonstrated by the PoC is direct user yield loss.
The attacker enters a market that should reject new deposits, becomes part of the active deposit reward share set, and receives emissions that would otherwise have remained with existing lenders.
The test demonstrates a four figure attacker reward and an equal lender loss.
At the same time, exploitation depends on positive protocol reserve and an active deposit liquidity mining campaign.
Those are realistic protocol states, but they are still required conditions.
That is why I reported the finding as Medium.
The fix is straightforward
The cap should use one consistent measure of depositor backing.
Since total_deposit_plus_interest() already excludes cash_reserve, the second reserve subtraction should be removed.
A safe check is conceptually equivalent to:
self.total_deposit_plus_interest().ceil()
+ increment
> limit
The key invariant is:
if current depositor backing
is already equal to max_deposit_amount,
any additional deposit must revert
regardless of cash_reserve
Regression coverage should exercise every normal reserve growth path that can expose the bug, including interest accrual, liquidation revenue, over repayment, and flash loan fees.
It is also worth keeping a reward focused regression test. A deposit cap bug can become a yield ownership bug when accepted deposits feed directly into liquidity mining shares.
The broader lesson
This finding is a good example of why derived accounting values need clear semantics.
If one helper already represents:
cash + borrows - reserves
then a downstream caller must not silently assume reserve still needs to be removed.
The arithmetic error here is small in code but crosses subsystem boundaries:
market risk control
↓
deposit admission
↓
reward share accounting
↓
user yield distribution
The useful audit question is not only whether a cap computes the correct number.
It is also what the rest of the protocol trusts once that cap says yes.
Conclusion
CurrentSui’s deposit cap gate used total_deposit_plus_interest(), a value already net of cash_reserve, and then subtracted cash_reserve again.
That understated utilization whenever protocol reserve was positive.
The PoC showed that a market already at its configured cap could accept an additional 8,000 USDC after exactly 8,000 USDC of reserve had accumulated.
Because a successful deposit immediately enters the liquidity mining share accounting, the extra depositor could then capture more than 1,000 USDC from an active reward campaign, with the original lender losing the same amount.
The underlying rule is simple:
A reserve accounting component should be removed exactly once from the metric used to enforce a market wide deposit cap.
When that rule is broken, a limit bypass can propagate into a completely different subsystem and become direct user yield loss.
Top comments (0)