DEV Community

Daniel
Daniel

Posted on

How a Zero-Liquidity Flash Swap Crossed Momentum Ticks for Free and Stalled Rewards

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 final reward was $100.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But it could still have:

pool.liquidity == 0
Enter fullscreen mode Exit fullscreen mode

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(),
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When liquidity was zero, both helpers returned zero.

Conceptually:

amount delta =
    function of liquidity and price movement

liquidity = 0

amount delta = 0
Enter fullscreen mode Exit fullscreen mode

The required input to reach the target price therefore became:

amount_in_delta = 0
Enter fullscreen mode Exit fullscreen mode

The exact-input branch evaluated a condition equivalent to:

amount_remaining_minus_fee >= amount_in_delta
Enter fullscreen mode Exit fullscreen mode

With a nonnegative remaining amount and amount_in_delta == 0, the condition was trivially true.

The step selected:

new_sqrt_price = target_sqrt_price
Enter fullscreen mode Exit fullscreen mode

while returning:

amount_in = 0
amount_out = 0
fee_amount = 0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

On the vulnerable path:

amount_in = 0
fee_amount = 0
Enter fullscreen mode Exit fullscreen mode

Therefore:

amount_specified_remaining
does not decrease
Enter fullscreen mode Exit fullscreen mode

Normally, that would indicate no progress.

But the loop also terminated when:

swap_state.sqrt_price == sqrt_price_limit
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Therefore:

amount_specified
-
amount_specified_remaining
=
0
Enter fullscreen mode Exit fullscreen mode

The calculated side also remained zero.

The receipt contained:

debt_x = 0
debt_y = 0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The setup created a pool and placed a liquidity position outside the current tick range.

The pool was initialized, but:

pool.liquidity == 0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The proof first established a clean reward baseline.

A second position was added inside the active range so that:

pool.liquidity > 0
Enter fullscreen mode Exit fullscreen mode

Time advanced while the reward emission was active.

The position successfully claimed rewards:

claimed > 0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and extended the emission period.

The test passed:

[ PASS ] poc_free_tick_cross_sticks_reward_state_until_admin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

From the CLMM package directory:

cd mmt-v3-core/clmm
sui move test
Enter fullscreen mode Exit fullscreen mode

The expected output included:

[ PASS ] poc_free_tick_cross_and_oracle_write_debt_zero
[ PASS ] poc_free_tick_cross_sticks_reward_state_until_admin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The base score was:

7.5 High
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The report mapped to:

Transaction manipulation

Attacks on logic

Loss or freezing of unclaimed yield
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and around:

10 swaps per minute
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Can that state occur during intended protocol operation?

  2. Can an unprivileged caller exploit it when it occurs?

  3. 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(),
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or:

If liquidity == 0
return a no-progress result
and require the caller to abort
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. flash_swap on an initialized pool with zero active liquidity

  2. A price limit equal to the next initialized tick boundary

  3. Both swap directions

  4. Exact-input and exact-output modes

  5. Empty regions between initialized ranges

  6. No tick crossing without positive economic flow

  7. No oracle write when a step makes no economic progress

  8. No zero-debt receipt after a committed pool-state transition

  9. Reward emissions before and after ended_at

  10. Claimability after a zero-liquidity transition

  11. No need for admin intervention to recover accrued rewards

  12. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)