# BlueMove Was Not an Overflow Bug — How a Cross-Version Reserve Desync Drained 714,000 SUI

On July 11, 2026, at 22:13 UTC, an attacker began draining liquidity pools on BlueMove DEX, a Sui-based AMM. Within 23 minutes, approximately 714,000 SUI — roughly $528,000 at the time — had crossed a Wormhole bridge as USDC. The contracts were immutable. The pools were empty. There was no way to patch or freeze.

BlueMove called it "a long-standing arithmetic overflow bug in the legacy AMM contract." Tyler Simpson of Quantum Void Labs called it a backdoor — a function shipped in a May 31 upgrade that set the stage for the drain.

Both accounts identify real pieces of the story. Neither captures the mechanism that made the drain possible.

I decompiled every module of both package versions, tested Move's integer behavior directly, and traced the attack transactions command by command. To the best of my knowledge, this is the first public analysis that identifies the actual exploit path: a **reserve desynchronization** between two callable versions of the same package. Both versions write `reserve_x` after every operation, but they write *different values* — V1 writes `pool.token_x.value()`, V-latest writes `escrow.token_x.value()`. When a V1 swap runs on a pool whose main liquidity lives in the escrow, `reserve_x` drops to the small `pool.token_x` balance, while the escrow retains the large one. The next V-latest `mint` divides by this deflated `reserve_x`, inflating LP tokens by the ratio between the two balances.

* * *

## Why the overflow claim does not hold

Move's integer arithmetic does not wrap on overflow. Every overflow — addition, multiplication, or out-of-range downcast — aborts the transaction at runtime. This is not a compiler flag; it is how the Move VM executes arithmetic.

I tested this directly with Sui CLI v1.74.1:

```move
#[test]
#[expected_failure]
fun test_u128_mul_overflow() {
    let a: u128 = 340282366920938463463374607431768211455; // u128::MAX
    let _result = a * 2; // aborts — does not wrap
}

#[test]
#[expected_failure]
fun test_u128_add_overflow() {
    let a: u128 = 340282366920938463463374607431768211455;
    let _result = a + 1; // aborts
}

#[test]
#[expected_failure]
fun test_u64_downcast() {
    let big: u128 = 18446744073709551616; // u64::MAX + 1
    let _small = (big as u64); // aborts — does not truncate
}
```

All three pass with `expected_failure`. Each operation aborts at runtime. An arithmetic overflow in Move is a denial-of-service, never a fund extraction vector.

* * *

## The decompiled code

BlueMove never published source code. I decompiled all modules using [Revela](https://github.com/verichains/revela) (v1.0.0) for bytecode v6, and `sui move disassemble` for bytecode v7 (unsupported by Revela). Critical paths were cross-verified between the two tools.

### Three packages

On Sui, upgrading a package does not replace the original. Both versions remain callable on the same shared objects. BlueMove's Pool objects are shared, so any version can read and mutate them.

| Label | Package ID | Bytecode |
| --- | --- | --- |
| **V1** | `0xb24b6789...145454d9` | v6 — Original (2023). Decompiled with Revela. |
| **V-mid** | `0x08cd3348...3244498f` | v6 — Intermediate. Introduced `EscownCoins` (the typo is in the deployed bytecode). |
| **V-latest** | `0x35f3190a...0f656d7` | v7 — May 31 upgrade. `EscrowCoinsV2`, `add_liquidity_returns`. **This is the package the attacker called.** |

### Two token stores, one `reserve_x` field

V1 stores tokens in `pool.token_x` / `pool.token_y`. V-latest stores tokens in `EscrowCoinsV2`, a dynamic object field on the Pool. Both versions share the Pool struct, including a single `reserve_x: u64` field. Both versions overwrite `reserve_x` after every operation — but with values from different balances.

* * *

## The reserve write: code proof

This is the linchpin. Each version's `swap` function ends with `update(x, y, pool)`, which sets `pool.reserve_x = x`. The question is: what is `x`?

### V1 swap → `reserve_x = pool.token_x.value()`

From V1's decompiled code (Revela, `swap.move`):

```move
// V1 swap (lines 96, 122):
let (v2, v3) = token_balances<T0, T1>(arg2);
//  v2 = balance::value(&pool.token_x)    ← actual pool balance
//  v3 = balance::value(&pool.token_y)
...
update<T0, T1>(v2, v3, arg2);
//  pool.reserve_x = v2 = pool.token_x.value()

// V1 update (lines 80–83):
fun update<T0, T1>(arg0: u64, arg1: u64, arg2: &mut Pool<T0, T1>) {
    arg2.reserve_x = arg0;
    arg2.reserve_y = arg1;
}
```

This invariant holds immediately after every successful V1 swap because `update()` is always called with the current pool balances.

After every V1 swap: `reserve_x == pool.token_x.value()`. Always.

### V-latest swap → `reserve_x = escrow.token_x.value()`

From V-latest's disassembly (escrow-exists branch, `sui move disassemble`):

```plaintext
// V-latest swap, escrow branch (lines 105–112, 269–272):
L105: CopyLoc[38](loc32: &mut EscrowCoinsV2)
L106: ImmBorrowFieldGeneric[11](EscrowCoinsV2.token_x)
L107: Call balance::value → loc18          ← escrow balance
L108: MoveLoc[38]
L109: ImmBorrowFieldGeneric[12](EscrowCoinsV2.token_y)
L110: Call balance::value → loc24          ← escrow balance
...
L269: MoveLoc[24](loc18)                   ← escrow.token_x.value()
L270: MoveLoc[30](loc24)                   ← escrow.token_y.value()
L271: MoveLoc[2](Pool)
L272: Call update                          ← reserve_x = escrow balance
```

After every V-latest swap (escrow branch): `reserve_x == escrow.token_x.value()`.

This invariant holds only for the escrow branch. Therefore, whichever version executes last determines the meaning of `reserve_x`.

### The consequence

`reserve_x` is a single field written by two functions that compute it from different balances. Whichever version runs last determines the value. If V-latest ran last, `reserve_x` tracks the escrow. If V1 ran last, `reserve_x` tracks `pool.token_x`. The two balances are independent — one can be orders of magnitude larger than the other.

* * *

## The attack

### Pool lifecycle

MovePump launchpad pools follow this lifecycle:

1.  **Created through V-latest** (`create_and_freeze_pool` → `unfreeze_and_add_liquidity` → `add_liquidity_direct`). Initial liquidity goes into `EscrowCoinsV2`. `pool.token_x` stays at zero. `reserve_x` = escrow balance. LP tokens are minted to the pool creator.
    
2.  **Traded through V-latest.** Users swap via V-latest router. Tokens move in and out of the escrow. `reserve_x` tracks escrow balance. `pool.token_x` remains empty. Over weeks, the escrow accumulates a large SUI balance from trading.
    
3.  **Traded through V1.** External aggregators (e.g., OKX DEX Router, whose `Move.toml` references V1 package `0xb24b67...`) route swaps through V1. V1 deposits input tokens into `pool.token_x` and sets `reserve_x = pool.token_x.value()` — a much smaller number than the escrow balance. **This is the desync.**
    

### Step by step

**Before attack:**

```plaintext
escrow.token_x  = 350,000 SUI     (accumulated from V-latest trading)
pool.token_x    = 7,022 SUI       (accumulated from V1 aggregator swaps)
reserve_x       = 7,022            (set by last V1 swap = pool.token_x)
lp_supply       = 1,000            (from initial mint)
```

`reserve_x` and `pool.token_x` are equal (V1's update synchronizes them). But `escrow.token_x` is 50× larger — V1 doesn't see it, and the last operation was a V1 swap.

The numerical values below are representative rather than extracted from a single pool. They are chosen to satisfy the observed invariants (`reserve_x == pool.token_x` after a V1 swap) and to illustrate the ratio between the escrow balance and the pool balance that drives the LP inflation.

**Step 1 — V-latest** `add_liquidity_returns`**(0.057 SUI, meme tokens):**

The B20 branch runs (escrow exists). The deposit joins the escrow:

```plaintext
escrow.token_x += 0.057 → escrow_total = 350,000.057
```

V-latest's `mint` runs with `lp_supply > 0` (the standard proportional branch — not the first-deposit branch):

```plaintext
LP_minted = (escrow_total − reserve_x) × old_supply ÷ reserve_x
          = (350,000.057 − 7,022) × 1,000 ÷ 7,022
          = 342,978.057 × 1,000 ÷ 7,022
          = 48,843
```

The attacker mints 48,843 LP — **48× the existing supply** — because `mint` divides by `reserve_x` (7,022), not by the escrow balance (350,000) that the tokens actually sit in. No overflow. No underflow. The standard formula, fed a stale denominator.

The minted LP tokens are **returned to the caller** (this is what `add_liquidity_returns` does differently from `add_liquidity`), so they flow directly to the next command.

**Step 2 — V1** `remove_liquidity`**(48,843 LP):**

V1's `burn` reads `balance::value(&pool.token_x)` — the actual SUI in the pool's direct balance, not the escrow:

```plaintext
total_supply  = 1,000 + 48,843 = 49,843
attacker_share = 48,843 ÷ 49,843 = 97.99%

withdrawal = pool.token_x.value() × attacker_lp ÷ total_supply
           = 7,022 × 48,843 ÷ 49,843
           = 6,881 SUI
```

V1 splits 6,881 SUI from `pool.token_x` and transfers it to the attacker.

**Result:** The attacker deposited 0.057 SUI (into the escrow) and withdrew 6,881 SUI (from `pool.token_x`). The stolen funds are the SUI that accumulated in `pool.token_x` from V1 aggregator swaps — real user trades routed through the old package.

The escrow's 350,000 SUI is not directly stolen in this step. It serves as the inflated numerator in the `mint` formula, making the attacker's LP share large enough to claim nearly all of `pool.token_x`.

The calculation above is illustrative. The attack transaction follows the same sequence of calls, while the exact balances differ from pool to pool.

This pattern repeated per pool. At ~1,966 SUI average across 363 pools, the total reaches approximately 714,000 SUI.

* * *

## The attack transaction

Transaction [`8pMKBovvrHzyRiaGjUPPFuFX6a92KkqSRitZ36zbD1qz`](https://suiscan.xyz/mainnet/tx/8pMKBovvrHzyRiaGjUPPFuFX6a92KkqSRitZ36zbD1qz) — 49 programmable commands, 64 inputs. From the raw JSON, the command sequence repeats per pool:

```plaintext
SplitCoins  (prepare 0.057 SUI)
SplitCoins  (prepare meme token amount)
MoveCall    0x35f3190a... / router / add_liquidity_returns     ← V-latest
MoveCall    0xb24b6789... / router / remove_liquidity          ← V1
```

V-latest for deposit. V1 for withdrawal. Same Pool. Same transaction.

Pools drained in this single transaction include aaaMEME, SPANK, BSNAKE, JELLY, DGS, PCON, RUSHI, and others. On-chain balance changes for the aaaMEME pool: Add Liquidity +0.057 SUI, Remove Liquidity −7,022.21 SUI.

**Attacker:** [`0xb29e7919...097335`](https://suiscan.xyz/mainnet/account/0xb29e79198742e84ddf6a5a952238990f7d80565826ff3d97dc92d7056b097335) (Blockaid: MALICIOUS)

**Bridge wallet:** [`0xa74ee820...75065e`](https://suiscan.xyz/mainnet/account/0xa74ee820821d52994785eb9160b3a114a6338de1b3d9e188c00f09ba4f75065e)

**Exit:** SUI → USDC across 11 DEXes → Wormhole `deposit_for_burn` → ~$528,000 USDC off Sui in 23 minutes.

### Timeline

| Date | Event | Transaction |
| --- | --- | --- |
| May 31, 2026 | V-latest upgrade | [`9wTz16...`](https://suivision.xyz/txblock/9wTz16dgUXy4pce4TZor1ouTBUjc7f3TBxbVagTqQwHX) |
| June 3, 2026 | UpgradeCap burned | [`EugVQB...`](https://suivision.xyz/txblock/EugVQBpX8jp6GFkXSRfhe5cfCeXhwiipKndGpeNAkZRb) |
| July 11, 2026 | Pools drained | [`8pMKBo...`](https://suiscan.xyz/mainnet/tx/8pMKBovvrHzyRiaGjUPPFuFX6a92KkqSRitZ36zbD1qz) and others |

* * *

## Verdict

| Claim | Assessment | Evidence |
| --- | --- | --- |
| BlueMove: "arithmetic overflow" | **Does not hold.** Move aborts on overflow. | `sui move test` — three cases, all abort |
| BlueMove: "bug since 2023" | **Misleading.** V1 alone is internally consistent. The vulnerability requires V-latest's escrow to create the reserve desync. | V1 decompiled — no escrow, no split state |
| BlueMove: "UpgradeCap burned" | **Correct.** | Suiscan: Immutable |
| Simpson: "`add_liquidity_returns` is key" | **Correct.** Returns LP to caller for same-TX burn. | Attack TX command list |
| Simpson: "double-mint LP inflation" | **Imprecise.** The LP formula is standard. The inflation comes from a desynced `reserve_x` denominator, not from minting twice. | V1 vs V-latest reserve update paths |
| Simpson: "backdoor" | **Cannot be resolved from code.** | — |

* * *

## The vulnerability class

The root cause is a **shared mutable field written by incompatible update functions**. Both V1 and V-latest write `pool.reserve_x` after every swap, but from different sources:

*   V1: `reserve_x ← pool.token_x.value()`
    
*   V-latest: `reserve_x ← escrow.token_x.value()`
    

When the two balances diverge (because tokens accumulate in different stores), whichever version wrote `reserve_x` last determines the denominator for LP minting. If the small balance wrote last, LP is inflated. If the large balance wrote last, LP is correctly priced.

This generalizes to any package upgrade that satisfies three conditions:

1.  **Storage relocation**: Version B stores assets in a new location (dynamic object field, separate struct, etc.)
    
2.  **Shared accounting field**: Both versions overwrite the same field (`reserve_x`) with values from their respective stores
    
3.  **Old version remains callable**: Version A's functions are not disabled after Version B deploys
    

The pattern is analogous to a **schema migration without backward compatibility** — except the old schema is not just readable but writable, and both schemas race on the same live accounting field.

* * *

## What this analysis did not cover

*   **Intent.** The 40-day gap between the upgrade and the exploit is consistent with both a planted vulnerability and independent discovery. The code shows the mechanism; it does not show motive.
    
*   **Complete transaction accounting.** The four "pure sweep" transfers documented by Simpson total ~256,000 SUI; additional drain transactions account for the remaining ~458,000 SUI. Simpson counted 363 affected pools; BlueMove's statement said 389.
    
*   **Whether V1 swaps preceding the attack were organic or attacker-triggered.** The desync requires at least one V1 swap to set `reserve_x = pool.token_x.value()`. External aggregators referencing V1 would produce this naturally, but the attacker could also have triggered V1 swaps as setup within the same programmable transaction.
    
*   **Pre-attack pool state.** The numerical example uses representative values (escrow = 350,000, pool.token\_x = 7,022, supply = 1,000) that are consistent with the on-chain balance changes (+0.057 / −7,022) and with the V1 update rule (reserve\_x == pool.token\_x). Exact pre-attack values for individual pools were not extracted.
    
*   **The** `migrate_escrow_coins` **function** in V-latest, which moves balances between the V-mid and V-latest escrow structs.
    

* * *

## Methodology

*   **Decompilation**: Revela v1.0.0 on V1 and V-mid (bytecode v6). `sui move disassemble` on V-latest (bytecode v7).
    
*   **Cross-verification**: mint, burn, swap, add\_liquidity\_direct verified between decompiler output and raw disassembly. Register assignments for reserve\_x writes traced instruction-by-instruction: V1 swap L96/L122, V-latest swap L105–112/L269–272, V-latest swap else-branch L319–326/L483–486.
    
*   **Overflow testing**: `sui move test`, Sui CLI v1.74.1. Three cases, all abort.
    
*   **On-chain tracing**: Suiscan Raw JSON for attack transaction. Package addresses extracted from MoveCall commands. Two distinct packages confirmed: `0x35f319...` (add\_liquidity\_returns) and `0xb24b67...` (remove\_liquidity).
    
*   **Mathematical verification**: LP minting and burn withdrawal computed with representative values satisfying the V1 update invariant (reserve\_x == pool.token\_x). Result (attacker share 97.99%, withdrawal 6,881 SUI) is consistent with the on-chain balance changes for the aaaMEME pool.
    

The `EscownCoins` spelling (V-mid) and `EscrowCoinsV2` (V-latest) are exactly as they appear in the deployed bytecode.

* * *

*I build* [*move-test-gen*](https://github.com/mehvetero/move-test-gen)*, an open-source coverage checker and security linter for Sui Move. This analysis started with the linter flagging BlueMove's* `overflow_add` *function — which turned out to be irrelevant. The finding came from decompiling the bytecode and tracing the reserve update paths across package versions.*

*Upgrading a storage layout without retiring the previous entry points creates two valid writers for a single accounting field. Once those writers disagree about what the field represents, every formula downstream — LP pricing, withdrawal calculation, K-invariant verification — computes a correct answer to the wrong question.*
