The BlueMove DEX Exploit: Cross-Version Reserve Desynchronization and LP Inflation
Author: MoveBit (@MoveBit_)
Date: October 21, 2026
Source: X Post
Introduction: Why Upgrading a Package Does Not Eliminate Legacy Security Risks
Upgrading a Sui Move Package does not automatically disable its previous versions. Each upgrade publishes a new Package ID, while earlier versions remain on-chain. If other modules, routers, or clients continue to call a legacy Package, and multiple versions can modify the same Shared Object, conflicting assumptions about shared state can introduce cross-version vulnerabilities.
This risk is particularly significant in DeFi protocols. Changes to asset storage, reserve accounting, or business logic can leave legacy execution paths operating under outdated assumptions. When different Package versions modify the same state, individually valid operations can combine to violate the protocol's accounting invariants and create exploitable discrepancies.
On July 11, 2026, BlueMove, a DEX in the Sui ecosystem, suffered an exploit. A technical analysis published on the Sui Developer Forum in August identified inconsistent updates to the shared reserve_x field across callable Package versions as a key factor. The analysis estimated that approximately 714,000 SUI was drained over roughly 23 minutes, representing a loss of about $528,000 at the time.
The key takeaway: securing a Sui upgrade requires verifying every still-callable Package version, not just the latest implementation. All versions must preserve the same set of Shared Object state invariants.
MoveBit, a flagship sub-brand of BitsLab, examines the incident through the lens of Sui Package versioning, Shared Object security, and Move smart contract auditing. Drawing on publicly available bytecode decompilation and on-chain analysis, this article traces the reserve-accounting flaw, explains how it inflated LP minting and enabled withdrawals, and examines practical approaches to isolating legacy code and migrating state safely.
BlueMove's original source code has not been publicly released. The technical analysis below is based on publicly available bytecode decompilation and on-chain transaction research. All code snippets are simplified illustrations of the underlying mechanisms, not verbatim reproductions of the project's source code.
1. Sui Package Upgrades: Why Legacy Code Remains Callable
1.1 Each Upgrade Creates a New Package ID
Sui Move Packages are immutable on-chain code objects. An upgrade publishes a new Package version rather than modifying the existing version in place.
A protocol may therefore have multiple deployed versions:
- V1 — Package ID:
0x...(original) - V2 — Package ID:
0x...(upgrade) - V3 — Package ID:
0x...(latest)
Each version has its own Package ID. A transaction invokes the version referenced by its call path. Dependencies that reference an older Package do not automatically switch to the latest version when an upstream Package is upgraded.
Deploying V3 therefore does not, by itself, invalidate V1. If legacy functions remain callable and can access the relevant objects, they may continue to execute.
Reference: Sui Documentation: Package Versioning
1.2 Packages Define Logic; Shared Objects Hold State
In an automated market maker (AMM), the Package defines swap, liquidity provision, withdrawal, and reserve-update logic. Shared Objects hold the pool's balances, reserves, and LP supply.
The architecture can be represented as follows:
┌─────────────────────────────────────────┐
│ Shared Pool Object │
│ ┌─────────────────────────────────┐ │
│ │ reserve_x / reserve_y │ │
│ │ lp_supply │ │
│ │ token_x / token_y (or Escrow) │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
↑ ↑
│ │
┌────┴────┐ ┌────┴────┐
│ V1 │ │ V3 │
│ Package │ │ Package │
└─────────┘ └─────────┘
When multiple Package versions can modify the same Pool, they must preserve consistent state invariants.
Suppose V1 stores assets directly in pool.token_x, while V3 moves them into EscrowCoinsV2. If both versions update reserve_x using different balance sources, the reserve field can become inconsistent with the actual distribution of assets.
Each version may behave as designed in isolation. The vulnerability emerges when their differing assumptions collide on the same Shared Object.
1.3 Upgrade Compatibility Does Not Guarantee Business-Logic Consistency
Sui's Package upgrade compatibility rules govern aspects such as public function signatures and struct layouts. Passing these checks does not guarantee that different versions preserve the same business invariants.
For example, V1 may treat reserve_x as the Pool's directly held balance, while V3 treats it as the balance held in Escrow. If both versions can update the same field, its value may no longer have a consistent meaning across the protocol.
A secure upgrade therefore requires more than checking the latest code. It also requires validating shared-state semantics, business invariants, and execution paths across Package versions.
2. Root Cause: Two Package Versions Update reserve_x Using Different Balances
Public bytecode analysis identifies two callable Package versions as central to the BlueMove incident.
- V1 stores assets in the Pool's
token_xandtoken_yfields. - The newer version uses
EscrowCoinsV2for asset storage while continuing to maintain reserve values in shared Pool fields.
2.1 V1 Reserve-Update Logic
The relevant V1 logic can be represented as follows:
// Simplified V1 reserve update
fun update_reserves(pool: &mut Pool) {
pool.reserve_x = pool.token_x.value();
pool.reserve_y = pool.token_y.value();
}
V1 derives the reserves from the balances held directly in the Pool. After this update, the following invariant holds:
pool.reserve_x == pool.token_x.value()
This relationship remains valid as long as subsequent V1 calculations use the same balance source.
2.2 Reserve-Update Logic in the Newer Version
The newer version derives its reserves from Escrow:
// Simplified newer version reserve update
fun update_reserves(pool: &mut Pool, escrow: &EscrowCoinsV2) {
pool.reserve_x = escrow.token_x.value();
pool.reserve_y = escrow.token_y.value();
}
This implementation maintains a different invariant:
pool.reserve_x == escrow.token_x.value()
The difference is fundamental:
| Version | Balance Source for reserve_x |
Meaning of reserve_x |
|---|---|---|
| V1 | pool.token_x |
Direct Pool balance |
| Newer | escrow.token_x |
Escrow balance |
When the two balances differ, the versions can write different values into the same field.
The last reserve update can change the meaning of reserve_x: V1 treats it as the Pool's direct balance, while the newer version treats it as the Escrow balance.
2.3 Why This Is a Cross-Version Vulnerability
Consider the following state:
Escrow balance: 350,000 SUI
Pool direct balance: 7,022 SUI
Shared reserve_x: 7,022 SUI
Here, reserve_x matches the Pool's direct balance but is roughly 50 times smaller than the Escrow balance.
If the newer version uses reserve_x as the denominator in its LP minting formula, it can misinterpret the difference between these balances as newly added liquidity.
V1's reserve update may be internally consistent, and the newer version's LP formula may be arithmetically correct. The flaw arises when the newer version interprets a value written under V1's accounting model as though it represented the Escrow balance.
This is why cross-version security analysis must cover historical Packages, shared objects, and state transitions across functions rather than focusing exclusively on the latest implementation.
3. How LP Minting Amplifies the Accounting Flaw
3.1 LP Minting Must Reflect Actual Deposits
In a simplified AMM, the amount of LP tokens minted can be expressed as:
ΔLP = S × (Δx / R_x)
Where:
Δxis the amount of assets actually deposited.Sis the LP supply before minting.R_xis the corresponding reserve under the authoritative accounting model.
Both the deposit amount and the reserve must reflect the same asset state. If reserve_x is inconsistent with the actual reserve, the formula can produce an inflated result without any arithmetic overflow.
3.2 The BlueMove Flaw: Miscalculating the Deposit Amount
The relevant minting logic can be simplified as follows:
// Simplified LP minting (newer version)
fun mint_lp(pool: &mut Pool, deposit_x: u64) -> u64 {
let current_x = escrow.token_x.value(); // Actual Escrow balance
let delta = current_x - pool.reserve_x; // Assumes reserve_x == previous Escrow
let lp_to_mint = pool.lp_supply * delta / pool.reserve_x;
// ...
}
The calculation assumes that pool.reserve_x represents the Escrow balance before the deposit.
If V1 has overwritten reserve_x with the Pool's direct balance, then:
current_x= Escrow balancereserve_x= Pool direct balance
The expression current_x - reserve_x no longer measures the assets deposited in the current transaction. Instead, it includes the historical difference between two separate balances.
The denominator is also too small, further inflating the LP output.
The attacker can therefore exploit inconsistent accounting semantics without triggering an integer overflow.
4. Numerical Walkthrough: How a Small Deposit Can Produce Excessive LP Shares
The following figures illustrate the mechanism described in the public analysis. They are representative values, not a complete historical snapshot of a specific pool.
4.1 Initial State
Escrow balance: 350,000 SUI
Pool balance: 7,022 SUI
reserve_x: 7,022 SUI
LP supply: 1,000
A V1 swap updates reserve_x to the Pool's direct balance. Although Escrow holds 350,000 SUI, the newer version uses 7,022 SUI as its reserve baseline.
4.2 Step One: Add Liquidity Through the Newer Version
The attacker deposits approximately 0.057 SUI into the Escrow used by the newer version, alongside the corresponding amount of the other token.
The updated Escrow balance becomes:
350,000 + 0.057 = 350,000.057 SUI
However, reserve_x remains at 7,022 SUI.
Δx (perceived) = 350,000.057 - 7,022 ≈ 342,978 SUI
ΔLP = 1,000 × (342,978 / 7,022) ≈ 48,843 LP tokens
A deposit of approximately 0.057 SUI thus produces around 48,843 LP tokens, roughly 48.8 times the existing LP supply.
The problem is the incorrect comparison between Escrow's balance and the Pool's direct balance, not an arithmetic overflow.
4.3 Step Two: Withdraw Liquidity Through V1
According to the public analysis, the attack then calls V1's remove_liquidity function. The legacy withdrawal logic reads the Pool's direct balance rather than the Escrow balance.
Its calculation can be simplified as follows:
Attacker share = new_lp / total_lp
Withdrawal = pool.token_x.value() × attacker_share
Using the representative figures:
Existing LP supply = 1,000
New LP minted = 48,843
Total LP supply = 49,843
Attacker share = 48,843 / 49,843 ≈ 97.99%
Withdrawal ≈ 7,022 × 0.9799 ≈ 6,881 SUI
The attacker can therefore withdraw approximately 6,881 SUI from the Pool's direct balance.
These assets come from pool.token_x, not directly from the 350,000 SUI held in Escrow. The Escrow balance inflates the LP minting calculation, allowing the attacker to claim a disproportionate share of the Pool's directly held assets.
4.4 Why the Attack Works Within a Single Transaction
The public analysis indicates that the attacker used a Programmable Transaction Block (PTB) to call the newer version's add_liquidity_returns function and then V1's remove_liquidity function within the same transaction.
PTB:
1. Call newer.add_liquidity_returns(...) → returns LP tokens
2. Call V1.remove_liquidity(LP tokens) → withdraws from Pool
The LP tokens returned by add_liquidity_returns can be passed to subsequent commands. This allows the attacker to combine the newer version's minting logic with the legacy withdrawal logic in a single transaction.
The public analysis describes a transaction containing 49 programmable commands and repeated call sequences across multiple pools. It estimates that approximately 363 pools were affected, with total transfers of around 714,000 SUI.
Source: Sui Developer Forum: BlueMove Cross-Version Reserve Desynchronization Analysis
5. Why Destroying the UpgradeCap Does Not Disable Legacy Code
The public analysis reports that BlueMove's UpgradeCap was destroyed on June 3, 2026, while the exploit occurred on July 11.
Destroying an UpgradeCap can permanently prevent further upgrades to a Package. It does not remove historical Packages from the blockchain or invalidate their functions. Legacy code can remain callable through its original Package ID.
Upgrade authorization and legacy-code control are separate security concerns. Preventing further upgrades does not prevent an older version from modifying shared state.
Reference: Sui Documentation: Upgrading Packages
6. Remediation: Object Version Checks and Legacy-State Isolation
The appropriate remediation depends on whether the protocol incorporated version control into its Shared Object design from the outset.
6.1 Approach One: Add Object Version Checks in the Initial Design
Sui recommends designing Package version management into systems that interact with Shared Objects.
The following example illustrates a version check:
const VERSION: u64 = 1;
struct Pool has key {
id: UID,
version: u64,
// ... other fields
}
public fun swap(pool: &mut Pool, ...) {
assert!(pool.version == VERSION, E_WRONG_VERSION);
// ... swap logic
}
When publishing a new version, the module's VERSION can be increased to 2, with a migration function protected by an appropriate capability:
const VERSION: u64 = 2;
public fun migrate(pool: &mut Pool, _: &AdminCap) {
assert!(pool.version == 1, E_WRONG_VERSION);
pool.version = 2;
// ... migrate state if needed
}
These snippets illustrate the mechanism rather than provide a complete implementation. Production code must account for the actual module types, authorization rules, and migration requirements. Passing an AdminCap illustrates how a capability object can restrict migration privileges.
After migration, the Pool's version becomes 2. A legacy function will abort on a version mismatch only if that version already checks pool.version == VERSION at the relevant entry points.
The critical limitation is that adding a version check to the new Package cannot change the behavior of legacy code that never implemented one. If V1 lacks the check, adding it to V2 does not disable V1.
Sui also imposes struct-layout compatibility requirements on upgrades. Developers cannot assume that arbitrary fields can be added to an already published struct. Version fields and migration mechanisms should therefore be considered during the initial design.
Reference: Sui Documentation: Upgrading Packages
6.2 Approach Two: Introduce a New Object Type When Legacy Code Lacks Version Checks
If a deployed legacy version lacks version checks and its behavior cannot be changed through a compatible upgrade, the protocol may need to introduce a new object type and migrate the state into it.
struct PoolV2 has key {
id: UID,
// ... new fields with correct accounting
}
public fun migrate_to_v2(old_pool: Pool, _: &AdminCap): PoolV2 {
// Transfer assets, validate balances
// Return new PoolV2
}
The migration process should:
- Verify authorization through a protected administrative entry point.
- Transfer assets and required state from the legacy object to the new
PoolV2object. - Validate balances, reserves, and LP supply before and after migration.
- Emit migration events recording the relationship between the old and new objects.
- Update routers, SDKs, and other dependencies to use the new object.
- Deprecate the old object and prevent it from being used in normal protocol operations.
Creating PoolV2 alone does not disable the old Package. The protocol must verify that legacy execution paths cannot affect the new authoritative state or leave users exposed to obsolete asset-handling functions.
Sui's upgrade documentation discusses introducing new types and migration functions when legacy Shared Objects contain flawed logic. The new implementation can then accept only the new object type, breaking the compatibility relationship with the legacy object.
Effective remediation must address Package versions, object types, state migration, and dependent callers together. Adding access controls to the latest version does not change the behavior of historical Packages.
7. Regression Testing: Verifying the Cross-Version Fix
Testing should cover interactions between Package versions, not just the latest implementation. The goal is to verify that legacy execution paths cannot violate the protocol's state invariants.
7.1 Ensure Legacy Functions Cannot Operate on Migrated Objects
If object version checks are used, test the expected behavior:
Given:
Pool.version = 2
V1.VERSION = 1
When:
V1.swap(Pool)
Then:
Transaction aborts with E_WRONG_VERSION
This test is valid only if V1 already contains the version check. Testing V2 alone cannot prove that legacy code without such a check has been blocked.
7.2 Test Cross-Version Call Sequences
Test combinations such as:
V1.swap→V2.add_liquidityV2.swap→V1.remove_liquidityV1.update→V2.mintV2.migrate→V1.swap
Operations that are prohibited by design should abort or fail because of type isolation. Permitted combinations must preserve consistent asset balances, reserves, and LP supply.
7.3 Verify LP Minting Against Actual Deposits
Construct a test state similar to the one described in the BlueMove analysis:
Escrow balance = 350,000 SUI
Pool balance = 7,022 SUI
reserve_x = 7,022 SUI
LP supply = 1,000
Deposit = 0.057 SUI
When reserve_x differs from the authoritative reserve, the protocol should reject unsafe minting or ensure that historical balance differences cannot be counted as newly deposited assets.
Tests should verify the resulting LP amount against the actual deposit and the correct reserve baseline, rather than checking only whether the function executes successfully.
7.4 Verify Invariants Before and After Migration
For an AMM, regression tests should cover:
- Actual asset balances held by the Pool and Escrow.
- Consistency between
reserve_x,reserve_y, and the authoritative asset storage. - Total LP supply and withdrawal proportions.
- Authorization requirements for migration functions.
- Potential asset loss or duplicate accounting if migration fails.
Tests should include successful and repeated migrations, unauthorized calls, residual legacy objects, and attempts to invoke legacy code after migration.
8. Conclusion: Upgrade Security Must Cover Every Still-Callable Code Path
Sui's immutable Package model allows protocols to evolve while preserving historical code. When multiple versions can still access the same Shared Object, changes to storage structures, reserve calculations, and business rules can leave those versions operating against inconsistent state assumptions.
Addressing this risk requires coordinated Package version management, Shared Object version checks, legacy-object migration, dependency updates, and cross-version regression testing. True upgrade security means verifying that every still-callable Package version preserves the same set of state invariants.
BitsLab is a Web3 security company focused on smart contract auditing and security infrastructure for emerging blockchain ecosystems, including Sui, Aptos, Bitcoin, and TON. MoveBit, a flagship sub-brand of BitsLab, specializes in Move ecosystem security, providing smart contract audits and security research for projects building on Sui, Aptos, and other Move-based platforms.
Related Resources
Sui Official Documentation
MoveBit Research
- Sui Shared Object Security: How Access Control Flaws Put DeFi Protocols at Risk
- BlueMove Cross-Version Reserve Desynchronization Analysis
Official Websites


