Concentrated liquidity pools are built around a basic economic assumption:
Moving price through the curve should require token flow.
Momentum's CLMM violated that assumption when the pool was initialized but had zero active liquidity at the current tick.
In that state, mmt_v3::trade::flash_swap could move the pool price to the next initialized tick, execute a real tick::cross, change active liquidity, write oracle state on chain, and return a flash-swap receipt with zero debt.
No input was consumed.
No output was produced.
No swap fee was charged.
The first proof of concept demonstrated this free state transition directly.
After the initial severity discussion, I submitted a second deterministic Sui Move proof showing an additional protocol-level financial consequence: the same zero-liquidity tick-cross path could push reward accounting beyond the emission end time, after which previously claimable yield became unclaimable while reward custodian funds remained present and unchanged. Claimability returned only after an administrator extended the emission period.
The report was submitted as High and mapped to transaction manipulation, logic attacks, and loss or freezing of unclaimed yield. HackenProof validated the issue but kept the official severity at Low, focusing on the zero-liquidity precondition and the absence of demonstrated direct theft.
The missing invariant
A swap loop should preserve at least one of these properties during every iteration:
Input is consumed
Output is produced
A fee is charged
The remaining amount decreases
The transaction aborts
Momentum's zero-liquidity path allowed the loop to advance pool state without satisfying any of the first four conditions.
The swap made state progress without making economic progress.
That is the root of both proof-of-concept paths.
An initialized pool could still have zero active liquidity
Momentum distinguished between an uninitialized pool and a pool with no active liquidity at the current tick.
An initialized pool had:
pool.sqrt_price != 0
But it could still have:
pool.liquidity == 0
That state occurs when liquidity positions exist only outside the current price range.
flash_swap initialized its internal swap state from pool::liquidity(pool) and entered the swap loop without requiring the value to be positive.
The related flash_loan path did enforce that invariant:
assert!(
pool::liquidity(pool) > 0,
error::insufficient_liquidity(),
);
The inconsistency was important.
A flash loan with no active liquidity aborted immediately.
A flash swap with no active liquidity continued into swap mathematics that assumed price movement would remain tied to token deltas.
Why zero liquidity produced zero token amounts
Momentum calculated price movement through compute_swap_step.
That function relied on amount-delta helpers from sqrt_price_math, including:
get_amount_x_delta
get_amount_y_delta
When liquidity was zero, both helpers returned zero.
Conceptually:
amount delta =
function of liquidity and price movement
liquidity = 0
amount delta = 0
The required input to reach the target price therefore became:
amount_in_delta = 0
The exact-input branch evaluated a condition equivalent to:
amount_remaining_minus_fee >= amount_in_delta
With a nonnegative remaining amount and amount_in_delta == 0, the condition was trivially true.
The step selected:
new_sqrt_price = target_sqrt_price
while returning:
amount_in = 0
amount_out = 0
fee_amount = 0
The price moved even though the trade moved no tokens.
The loop could terminate without consuming the specified amount
In exact-input mode, flash_swap reduced the remaining amount by:
amount_in + fee_amount
On the vulnerable path:
amount_in = 0
fee_amount = 0
Therefore:
amount_specified_remaining
does not decrease
Normally, that would indicate no progress.
But the loop also terminated when:
swap_state.sqrt_price == sqrt_price_limit
Because compute_swap_step could move directly to the target with zero liquidity, the caller could choose a limit equal to the next initialized tick boundary.
The loop then reached the caller-selected price limit and exited even though no economic amount had changed.
Tick crossing committed meaningful pool state
Reaching an initialized boundary did more than update a displayed number.
The swap path executed tick::cross.
The transition could change:
sqrt_price
tick_index_current
active liquidity
tick accumulators
oracle observation state
The first proof demonstrated the following transition:
Before the call
Active liquidity
0
Current tick
Below the initialized position range
After the call
Current tick
Moved to the initialized lower tick
Active liquidity
Position liquidity
The pool entered a new active-liquidity state without the caller paying input or fees.
Oracle state was written on chain
The first proof also showed that the free tick transition reached the oracle update path.
When tick_index_current changed, the transaction updated oracle and tick-related cumulative state.
The important result was:
Returned swap balances
0
Receipt debt
0
sqrt_price
Changed
tick_index_current
Changed
Oracle cumulatives
Changed
Tick accumulators
Changed
This was not a simulation-only mutation.
The state was committed by the production flash_swap flow.
Downstream systems may interpret those values as evidence of economically paid market movement.
The vulnerable path broke that assumption.
Why the flash-swap receipt had zero debt
At the end of flash_swap, the receipt amounts were derived from the difference between the original specified amount and the remaining amount, together with the calculated output.
On the zero-liquidity path:
amount_specified_remaining
remained equal to
amount_specified
Therefore:
amount_specified
-
amount_specified_remaining
=
0
The calculated side also remained zero.
The receipt contained:
debt_x = 0
debt_y = 0
The caller could complete repayment using two zero balances.
The pool-state changes still persisted.
PoC 1: free tick crossing and oracle mutation
The first deterministic test was:
poc_free_tick_cross_and_oracle_write_debt_zero
The setup created a pool and placed a liquidity position outside the current tick range.
The pool was initialized, but:
pool.liquidity == 0
The test then called trade::flash_swap with a price limit at the next initialized tick.
It asserted:
Returned balance X
0
Returned balance Y
0
Receipt debt X
0
Receipt debt Y
0
Tick index
Changed
Square-root price
Changed
Oracle cumulatives
Changed
Tick accumulators
Changed
The receipt was repaid with zero balances.
The state transition remained committed.
The test passed:
[ PASS ] poc_free_tick_cross_and_oracle_write_debt_zero
PoC 2: reward funds became unclaimable until admin intervention
The second test extended the impact beyond pool-state integrity.
Its name was:
poc_free_tick_cross_sticks_reward_state_until_admin
The proof first established a clean reward baseline.
A second position was added inside the active range so that:
pool.liquidity > 0
Time advanced while the reward emission was active.
The position successfully claimed rewards:
claimed > 0
That baseline proved that the reward configuration, position, custodian, and collection path were functional.
The test then removed the in-range liquidity, returning the pool to:
pool.liquidity == 0
Time advanced past the reward emission's ended_at.
The same zero-liquidity flash_swap path was triggered to cross a tick for free.
After that transition, the proof observed:
reward_last_update_at
Moved from <= ended_at
to > ended_at
At the same time:
reward_growth_global
Did not increase
total_reward_allocated
Did not increase
collect::reward
Returned 0
Reward custodian balance
Remained greater than 0
Reward custodian balance over time
Remained unchanged
The rewards were no longer claimable through the normal user path even though the custodian still held funds.
Claimability returned only after an administrator called:
add_seconds_to_reward_emission
and extended the emission period.
The test passed:
[ PASS ] poc_free_tick_cross_sticks_reward_state_until_admin
Why the second proof mattered
The original severity discussion focused heavily on oracle integrity and the likelihood of a zero-liquidity state.
The second proof added a different impact dimension.
It showed that the free tick-cross path could interact with reward accounting so that:
Previously claimable yield
Became unclaimable
Reward custodian funds
Remained present
User collection
Returned zero
Recovery
Required explicit admin intervention
This was not direct theft.
It was also not merely a cosmetic oracle observation.
It demonstrated a protocol-level loss-of-access condition for unclaimed reward funds.
The final Low-severity rationale did not address this second deterministic reward-state path. It continued to focus on the likelihood of zero active liquidity and the absence of demonstrated direct fund loss.
Reproducing both tests
The attached repository was prepared with the proof module at:
sources/tests/poc_zero_liquidity_flash_swap_high_impact.move
From the CLMM package directory:
cd mmt-v3-core/clmm
sui move test
The expected output included:
[ PASS ] poc_free_tick_cross_and_oracle_write_debt_zero
[ PASS ] poc_free_tick_cross_sticks_reward_state_until_admin
Both tests were deterministic Sui Move unit tests.
They did not depend on front-running, a live RPC, or a race with another user.
The exploit was a direct single-transaction call
The attacker called flash_swap directly and selected the target price limit.
The relevant pool state was:
Initialized pool
YES
Active liquidity at the current tick
ZERO
Once that state existed, the first transition was deterministic in one transaction.
The attacker did not need to reorder a pending transaction, predict a private swap, or compromise a privileged account.
The second proof used the same permissionless state transition after the reward emission had ended.
Why I assessed the issue as High
I originally classified the report using:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
The base score was:
7.5 High
The first proof supported High integrity impact.
An unprivileged caller could modify:
pool.sqrt_price
pool.tick_index_current
pool.liquidity
oracle observation state
tick accumulators
without consuming input or paying fees.
The second proof strengthened the impact argument by showing a financial state consequence:
Reward funds remained in custody
but became unclaimable
until an administrator intervened
The report mapped to:
Transaction manipulation
Attacks on logic
Loss or freezing of unclaimed yield
The issue did not merely expose an internal helper returning unusual values.
Both tests reached the production swap path and committed protocol state.
Why triage kept the official severity at Low
Triage acknowledged that the free tick cross and on-chain oracle writes were real.
They maintained Low based primarily on likelihood.
At the time of reporting, Momentum's production pools reportedly had approximately:
$27 million TVL
and around:
10 swaps per minute
Their position was that liquidity == 0 at the current tick was not sustained or easily induced across active LP-covered ranges.
They also emphasized that the attacker could not create the zero-liquidity condition through this bug alone.
The attacker could exploit the path only when that state occurred naturally.
Once an LP supplied liquidity covering the current tick, the window closed.
For the oracle angle, triage stated that observations created while liquidity was zero carried zero seconds_per_liquidity weight and that well-designed TWAP consumers could filter them.
The final rationale also cited the absence of demonstrated direct loss of funds.
That reasoning addressed the first proof's likelihood and oracle impact.
It did not discuss the later reward proof showing previously claimable yield becoming inaccessible until admin intervention.
Why I disagree with the Low classification
The precondition should reduce severity from a continuously exploitable issue.
It does not make the behavior harmless.
The affected function is a permissionless production swap entry point.
When the legitimate zero-liquidity state exists, the attacker can deterministically:
Move price for free
Cross an initialized tick for free
Change active liquidity for free
Write oracle and tick accumulators for free
Complete the flash swap with zero debt
The second proof further showed:
Reward accounting can become stuck past ended_at
collect::reward can return zero
Custodian funds can remain unclaimable
Recovery can require administrator action
The attacker does not need to create the precondition for the exploit to have protocol impact.
Many vulnerabilities depend on a specific but legitimate state.
The relevant questions are:
Can that state occur during intended protocol operation?
Can an unprivileged caller exploit it when it occurs?
Does the exploit commit security-relevant or financially relevant state?
For these proofs, the answer to all three was yes.
The absence of direct theft prevents a Critical classification.
The zero-liquidity requirement reduces reliability.
But persistent price, tick, liquidity, oracle, and reward-state manipulation through a production entry point supports High more strongly than Low.
The official severity remains Low in this article for transparency.
Recommended remediation
The first correction should mirror the protection already present in flash_loan.
At the beginning of flash_swap:
assert!(
pool::liquidity(pool) > 0,
error::insufficient_liquidity(),
);
That prevents the known zero-active-liquidity path from entering swap mathematics.
Add a defensive guard to compute_swap_step
The mathematical helper should not silently advance price when liquidity is zero.
A defensive rule can be:
If liquidity == 0
abort
or:
If liquidity == 0
return a no-progress result
and require the caller to abort
The first option is simpler unless the protocol intentionally supports crossing empty ranges without token flow.
Enforce economic progress in every loop iteration
The swap loop should verify that each iteration made measurable economic progress.
A strong invariant is:
amount_specified_remaining decreased
OR
amount_in + amount_out + fee_amount > 0
If neither condition is true, the transaction should abort.
That protects against future arithmetic edge cases that could create free state transitions through a different path.
Protect reward accounting from no-flow transitions
Reward and oracle updates should not treat a zero-flow swap step as ordinary market activity.
The reward logic should preserve these properties:
reward_last_update_at
must not move past ended_at
in a way that strands accrued rewards
Claimable rewards
must remain claimable after emission ends
A zero-flow tick transition
must not require admin intervention
to restore user access
The protocol should add a regression test that reproduces the exact reward sequence from the second PoC and proves that custodian funds remain claimable without extending the emission.
Regression tests
A complete fix should test:
flash_swapon an initialized pool with zero active liquidityA price limit equal to the next initialized tick boundary
Both swap directions
Exact-input and exact-output modes
Empty regions between initialized ranges
No tick crossing without positive economic flow
No oracle write when a step makes no economic progress
No zero-debt receipt after a committed pool-state transition
Reward emissions before and after
ended_atClaimability after a zero-liquidity transition
No need for admin intervention to recover accrued rewards
Normal swaps and reward collection with active liquidity
The central invariant is:
A swap must not commit price, tick, liquidity, oracle, or reward-state changes unless the iteration makes economic progress.
Broader audit lessons
Initialization is not active liquidity
A pool can be initialized while having no active liquidity at the current tick.
Entry-point validation must check the state required by the mathematics.
Mathematical zeroes can bypass economic assumptions
A branch designed around:
remaining amount >= required input
becomes dangerous when the required input collapses to zero.
Every amount calculation needs boundary-value review.
Loop termination is not proof of progress
Reaching a price limit can terminate a swap even when the economic amount never changed.
Financial loops need explicit progress invariants.
Oracle and reward systems inherit swap-state assumptions
A downstream accumulator may assume that tick movement reflects paid trading activity.
If the swap layer violates that assumption, oracle and reward accounting can inherit the error.
Admin recovery is still a user-impact signal
Funds that become accessible only after privileged intervention are not functioning normally.
Even when custody is preserved, loss of permissionless claimability is a meaningful financial impact.
Conclusion
Momentum's flash_swap allowed an initialized pool with zero active liquidity to enter the normal swap loop.
With liquidity equal to zero, amount-delta functions returned zero.
compute_swap_step could then move directly to the target price while producing:
amount_in = 0
amount_out = 0
fee_amount = 0
The loop terminated when the caller-selected price limit was reached.
If that limit was an initialized tick boundary, the transaction crossed the tick, changed active liquidity, wrote pool and oracle state, and returned a flash-swap receipt with zero debt.
The first deterministic proof completed repayment with zero balances and committed those changes.
The second deterministic proof showed an additional financial consequence: after the free tick-cross path advanced reward state beyond ended_at, previously claimable yield became unclaimable, collect::reward returned zero, and reward custodian funds remained untouched until an administrator extended the emission period.
HackenProof acknowledged the code-level issue and the on-chain writes but kept the official severity at Low because exploitation depended on a naturally occurring zero-liquidity state and no direct theft was demonstrated.
I assessed the issue as High because an unprivileged caller could force persistent CLMM state changes and interfere with reward claimability without paying input or fees.
The engineering invariant is simple:
Price, liquidity, oracle, and reward state must not advance through a swap iteration that makes no economic progress.

Top comments (0)