August 11, 2026
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…
By mehvetero
9 min read
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:
#[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
}
#[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 (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):
// 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;
}
// 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):
// 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
// 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:
-
Created through V-latest (
create_and_freeze_pool→unfreeze_and_add_liquidity→add_liquidity_direct). Initial liquidity goes intoEscrowCoinsV2.pool.token_xstays at zero.reserve_x= escrow balance. LP tokens are minted to the pool creator. -
Traded through V-latest. Users swap via V-latest router. Tokens move in and out of the escrow.
reserve_xtracks escrow balance.pool.token_xremains empty. Over weeks, the escrow accumulates a large SUI balance from trading. -
Traded through V1. External aggregators (e.g., OKX DEX Router, whose
Move.tomlreferences V1 package0xb24b67…) route swaps through V1. V1 deposits input tokens intopool.token_xand setsreserve_x = pool.token_x.value()— a much smaller number than the escrow balance. This is the desync.
### Step by step
Before attack:
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)
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:
escrow.token_x += 0.057 → escrow_total = 350,000.057
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):
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
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:
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
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 — 49 programmable commands, 64 inputs. From the raw JSON, the command sequence repeats per pool:
SplitCoins (prepare 0.057 SUI)
SplitCoins (prepare meme token amount)
MoveCall 0x35f3190a… / router / add_liquidity_returns ← V-latest
MoveCall 0xb24b6789… / router / remove_liquidity ← V1
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 (Blockaid: MALICIOUS)
Bridge wallet: 0xa74ee820…75065e
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… |
| June 3, 2026 | UpgradeCap burned | EugVQB… |
| July 11, 2026 | Pools drained | 8pMKBo… 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:
-
Storage relocation: Version B stores assets in a new location (dynamic object field, separate struct, etc.)
-
Shared accounting field: Both versions overwrite the same field (
reserve_x) with values from their respective stores -
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_coinsfunction 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 disassembleon 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) and0xb24b67…(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, 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.