Introduction
In Part 1, we covered what the Move language guarantees and what it does not. In Part 2, we examined how capabilities leak, access control fails, and resources break at the protocol level.
Part 3 shifts focus from who can do what to whether the math is right. By dollar value, arithmetic and precision bugs are the most punishing class of Move vulnerabilities. The ~$223M Cetus exploit was not an access control failure or a capability leak. It came down to a single flawed overflow check in a math library that three separate audits missed.
Move's VM aborts on addition, subtraction, and multiplication overflow, and developers quickly internalize this as "Move handles overflow for me." That mental model is incomplete. Integer division always truncates toward zero (even when the result is later used as money), and protocols still lean heavily on custom math libraries for wide-integer and fixed-point work, where errors are usually library-level logic bugs, not VM-level overflow bugs. Arithmetic precision errors rank among the most common findings in Zellic's Top 10 Aptos Move Bugs (#5 and #7), and the MoveScan study (Song et al., ISSTA 2024) identified arithmetic defects as one of the two most prevalent and financially dangerous bug categories across 37,302 deployed Move contracts. Hacken's 2025 Yearly Security Report puts total 2025 Web3 losses at $4B, with roughly $512M in DeFi losses driven by smart contract vulnerabilities, and names "more sophisticated attacks on DeFi protocols (mostly exploiting rounding vulnerabilities)" as the defining trend shift of 2025 versus 2024.
This article covers the patterns: overflow, multiplication-division precision loss, dust and share inflation, silent downcasts, generics confusion, encoding mismatches, and oracle/price issues, grounded in real findings.
How Move Arithmetic Works (and Doesn't)
Solidity (post-0.8) and Move both abort on integer overflow for the basic operations. The mental models then diverge. Solidity treats overflow checks as a property of every arithmetic operator, applied uniformly. Move treats them as a property of the typed VM-level instructions, which means the abort discipline is broken in three places that matter:

The Aptos Security Guidelines §Integer Considerations document this explicitly. The exception list is short, but it is exactly where the most expensive Move exploits have been. Cetus rode through bit-shift; Zellic's audit of Thala's StableSwap caught a u128 → u64 cast just before it shipped. Truncating division is responsible for an entire family of fee-rounds-to-zero, share-rounds-to-zero, and dust-accumulation bugs.
Key actors and trust boundaries
- Honest users: trusted to pass valid integer inputs, but the protocol must still validate amounts at boundaries (zero, max, dust). Trust boundary: input validation at every
entry fun. - Sophisticated traders/MEV actors: trusted to seek out parameter combinations that round in their favor. Trust boundary: every
a * b / cand rounding direction. - Attacker: any account that can submit transactions. Will engineer inputs that hit overflow corners, exploit signed/unsigned conversion boundaries, manipulate spot prices in the same PTB, and pass adversarial generic types. Trust boundary: every arithmetic operation that touches user-supplied numbers.
- Math library author (often the same team): trusted at audit time. Trust boundary: every line of every utility called from the protocol's solvency-critical paths. The Cetus bug lived in
integer-mate, not in Cetus itself.
Sections 2 and 3 describe attacks on specific boundaries: arithmetic invariants in the protocol (§2), and economic/type/encoding invariants that interact with arithmetic (§3).
Sui vs Aptos: Arithmetic Primitives
The two chains agree on most of the arithmetic surface but diverge enough to introduce fresh bugs. The table below maps the primitives reviewers need to know.
| Concern | Sui | Aptos |
|---|---|---|
Native integer types |
|
|
Overflow on | Aborts | Aborts |
Bit shifts ( | Abort if shift amount is too large (>= bit width); RHS must be | Abort if shift amount is too large (>= bit width); RHS must be |
Integer casts ( | Abort if value is out of range (casts do not truncate) | Abort if value is out of range (casts do not truncate) |
Integer division | Truncates toward zero; abort on division by zero | Truncates toward zero; abort on division by zero |
Standard fixed-point library | None. Protocols hand-roll (Aftermath |
|
Share/supply storage |
|
|
Time source |
|
|
Cross-chain payload decoding | Manual |
|
Same VM rules, slightly different ergonomics. The fixed-point library landscape and the cross-chain decoding semantics are the most common places where a fix that worked on one chain breaks on the other.
2. Arithmetic & Precision Vulnerabilities
Arithmetic vulnerabilities in Move rarely look like classic “wraparound overflow” anymore. Instead, they show up where the VM’s guarantees stop: shifts, casts, and truncating division, plus the wide-integer and fixed-point helpers teams build on top. This section groups the failure modes that repeatedly appear in audits and exploits, and frames each one in terms of the invariant it breaks and the kind of attacker who can systematically extract value from it.
2.1 Bit-Shift Overflow (The Cetus Pattern)
The Cetus exploit (May 22, 2025; ~$223M) is the headline case for this class. We dedicate a full case study below, but here we focus on the pattern itself.
Move's VM aborts on +, -, and *. Bit shifts (both << and >>) have their own abort rules (for example, aborting if the shift amount is out of range), and shift-based overflow checks are still easy to get wrong in custom wide-integer and fixed-point libraries. This exception class is where the most expensive Move bug shipped to date. Custom overflow checks around shifts have to be correct on their own, because the VM will not catch the mistake.
Simplified reconstruction:
// Note: the Cetus incident was a left shift (`<<`) overflow, but the broader
// issue is that Move does not VM-protect bit shifts in general.
//
// Example: checking u256 shift-left overflow into a 192-bit
// Result: value must fit in 192 bits. Abort if (value >> 192) != 0
// VULNERABLE: original `integer-mate` implementation used an incorrect
// threshold i.e. specific large values that should have aborted slipped through
// the guard. The Cetus exploit supplied exactly such a value to credit
// ~$223M of fake liquidity for 1 deposited token.
public fun checked_shlw(value: u256): (u256, bool) {
// BUG: this guard does not catch every value that would overflow the
// 192-bit target after `<< 64`.
if (some_incorrect_threshold_check(value)) {
return (0, false)
};
(value << 64, true) // Move's VM does NOT abort on `<<` overflow.
}
// SAFE: post-mortem fix. If any of the upper 64 bits are set, the result
// would overflow a 192-bit target after `<< 64`, so abort.
public fun checked_shlw(value: u256): (u256, bool) {
if (value >> 192 != 0) {
return (0, false)
};
(value << 64, true)
}General principle: always validate before shifting. Aptos Move Book (Integers): shifts abort if shift >= bit width for the integer type. Still, for custom wide-integer and fixed-point libraries, reviewers must validate both:
- the shift amount is in range, and
- the value fits the target bit width implied by the encoding.
assert!(shift_amount < 256, E_BAD_SHIFT);
assert!(value <= (U256_MAX >> shift_amount), E_WOULD_OVERFLOW); // for `<<`
let result = value << shift_amount;For right shifts (>>), the operation itself may not overflow, but it is still security-relevant when used for bounds checks, sign-bit tests, and encoding/decoding. Treat every shift-based check as part of the attack surface.
Sign-bit vs overflow confusion: valid negatives cause DoS (Medium/Sui)
Pattern: a hand-rolled signed integer (sign-magnitude on u256) conflates "top bit set" with "overflow".
// Context: this `ifixed` library encodes signed values in sign-magnitude,
// using the top bit of `u256` as the sign. The overflow check confuses
// "top bit set" (a valid negative) with "magnitude overflow", so every
// legitimately-negative product is rejected with an OVERFLOW_ERROR.
//
// Bit layout:
// z < GREATEST_BIT => magnitude OK, top bit clear
// z >= GREATEST_BIT => magnitude exceeds 2^(width-1), i.e., real overflow
// z | GREATEST_BIT => valid negative encoding (top bit set on purpose)
// VULNERABLE: aborts on every legitimately-negative product, since the
// negative encoding *requires* the top bit to be set.
public fun from_u256balance(balance: u256, scaling_factor: u256): u256 {
let z = balance * scaling_factor;
if (balance ^ scaling_factor < GREATEST_BIT) {
// Same sign => positive result => top bit must be clear. OK.
assert!(z < GREATEST_BIT, OVERFLOW_ERROR);
return z
};
// Opposite signs => negative result => top bit SHOULD be set.
// BUG: this treats "top bit set" as overflow and DoSes every valid input.
assert!(z <= GREATEST_BIT, OVERFLOW_ERROR);
(GREATEST_BIT - z) ^ GREATEST_BIT
}
// SAFE: separate magnitude check from sign encoding.
public fun from_u256balance(balance: u256, scaling_factor: u256): u256 {
let z = balance * scaling_factor;
assert!(z < GREATEST_BIT, OVERFLOW_ERROR); // pure magnitude check, sign-agnostic
if (balance ^ scaling_factor < GREATEST_BIT) z else (z | GREATEST_BIT)
}
// Same class as Cetus: hand-rolled signed types over `u256` are bug magnets because
// Move has no native `i256`.Wrong sign-bit position: negative values misclassified (Suggestion/Aptos)
Pattern: hand-rolled iN decoder compares against the wrong bit position. Off-by-one in the encoding, not the arithmetic.
// Context: a hand-rolled i192 decoder for Chainlink prices guards against
// out-of-range values by comparing against `1 << 192` instead of `1 << 191`.
// For a 192-bit signed integer, the sign bit lives at position 191 (anything
// with bit 191 set is already negative), so the off-by-one lets negatives
// in [2^191, 2^192) slip past the guard and be read as positive prices.
//
// Layout: width = 192, sign bit at position 191 (the high bit of 192).
// VULNERABLE:
fun parse_chainlink_price(price: u256): (Status, FixedPoint64) {
// BUG: off-by-one. (1 << 192) is one bit too high.
// Negatives in [2^191, 2^192) slip past this guard and are read as positive.
if (price >= (1 << 192)) { return (status::broken_status(), fixed_point64::zero()) };
// ... downstream code treats `price` as a positive magnitude ...
}
// SAFE:
fun parse_chainlink_price(price: u256): (Status, FixedPoint64) {
if (price >= (1 << 191)) { return (status::broken_status(), fixed_point64::zero()) };
// ...
}
// Same root cause as the previous finding: no native `iN` in Move/Aptos, so teams encode
// signedness on `u256` by hand and get the bit position wrong.2.2 Multiplication-Division Precision Loss
Precision loss happens when integer division truncates and silently drops value. Move arithmetic is entirely unsigned integer math with no native decimal support. A single a * b / c expression can fail two ways:
- the intermediate
a * boverflows the type, or - the division truncates toward zero and rounds away value an attacker can then extract.
This is not unique to Move, but the shape of the ecosystem makes it worse. Native u256 arrived on Aptos (Integers) later than on Sui (std::u256), standard fixed-point libraries are scarce, so teams routinely write their own precision math e.g. Cetus's integer-mate, and Thala's move-integers/fixed_point64.
The Hacken Move audit checklist calls out division truncation directly: "Move division truncates (rounds down). That can turn fees/interest into zero or cause systematic value loss." The Aptos Security Guidelines §Division Precision give a concrete version: if size is less than 10000 / PROTOCOL_FEE_BPS, the fee rounds to zero and the interaction is effectively free.
// VULNERABLE: Two problems, intermediate overflow + fee rounds to zero
public fun calculate_fee(amount: u64): u64 {
amount * PROTOCOL_FEE_BPS / 10000
// Problem 1: if amount and PROTOCOL_FEE_BPS are both large, multiplication overflows
// Problem 2: if amount < (10000 / PROTOCOL_FEE_BPS), result is 0 (no fee charged)
}
// SAFE: Use u128 intermediates + enforce minimum fee
public fun calculate_fee(amount: u64): u64 {
assert!(amount > 0, E_ZERO_AMOUNT);
let fee = ((amount as u128) * (PROTOCOL_FEE_BPS as u128) / 10000u128) as u64;
// Enforce minimum order size to prevent fee rounding to zero
assert!(fee > 0, E_FEE_ROUNDS_TO_ZERO); // Or enforce MIN_ORDER_SIZE upstream
fee
}Rounding direction matters for security:
- When calculating fees, rounding down means the protocol collects less than intended
- When calculating user share values, rounding down means users extract slightly more over time
- Consistently rounding in one direction creates a systematic drain opportunity
Division-before-multiply: value rounds to zero (Low/Sui)
Pattern: a ratio < 1 is computed with integer division, truncates to 0, and then kills the whole expression.
// Context: liquidity math wants `delta_l * num / denom`, where `num/denom`
// is a fractional ratio that is often < 1 in fixed-point terms. Doing the
// divide first truncates the ratio to 0, and the whole required-token
// amount collapses to 0, so downstream margin/liquidation logic then reads
// "this position needs 0 tokens".
public fun x_by_liquidity_x64(self: &PositionModel, sqrt_p_x64: u128, delta_l: u128): u128 {
[...]
// Target math: delta_l * num / denom
// Where num/denom is often < 1 (a fractional ratio in fixed-point terms).
let num = ((self.sqrt_pb_x64 - sqrt_p_x64) as u256) << 128;
let denom = (sqrt_p_x64 as u256) * (self.sqrt_pb_x64 as u256);
// VULNERABLE:
// 1) num / denom truncates toward 0.
// 2) If num < denom, num/denom == 0.
// 3) Then delta_l * 0 == 0, regardless of how large delta_l is.
// Result: downstream margin/liquidation logic sees “0 tokens required”.
let x_x64 = (delta_l as u256) * (num / denom);
[...]
}
// SAFE: multiply first (keep precision in the numerator), divide last.
public fun x_by_liquidity_x64_safe(self: &PositionModel, sqrt_p_x64: u128, delta_l: u128): u128 {
[...]
let num = ((self.sqrt_pb_x64 - sqrt_p_x64) as u256) << 128;
let denom = (sqrt_p_x64 as u256) * (self.sqrt_pb_x64 as u256);
// Multiplication inflates the numerator before truncation happens.
let x_x64 = ((delta_l as u256) * num) / denom;
[...]
}Fee rounds to zero, wrong fallback branch (Medium/Sui)
Pattern: “fee in token X” rounds to zero, and the code falls into a different branch that charges token Y (not a graceful fallback).
// Context: small orders cause the intended DEEP-token fee to truncate to 0.
// The `else if` branch isn't a graceful "fee-free" path; it routes into a
// quote-token penalty scaled by a multiplier. Users who expect a tiny DEEP
// fee silently get charged a much larger quote fee in a different unit.
public(package) fun fee_quantity([...]): Balances {
[...]
// deep_quantity is computed via integer math.
// For small orders, the intended DEEP fee can truncate to 0.
if (deep_quantity > 0) {
// Intended path: charge DEEP fee.
balances::new(0, 0, deep_quantity)
} else if (is_bid) {
// VULNERABLE FALLBACK:
// deep_quantity == 0 does NOT mean “fee-free”.
// It routes into a *different fee regime* (quote-token penalty).
// User expects “small DEEP fee”, gets “quote fee * multiplier”.
balances::new(
0,
math::mul(quote_quantity, constants::fee_penalty_multiplier()),
0,
)
} [...]
}
// SAFE (conceptual): treat "rounds to zero" as an error or enforce a min size.
public(package) fun fee_quantity_safe([...]): Balances {
[...]
// Option A: enforce minimum order size so intended fee never truncates to 0.
assert!(deep_quantity > 0, E_FEE_ROUNDS_TO_ZERO);
balances::new(0, 0, deep_quantity)
// Option B: if you truly want a fallback, it must be equivalent in meaning
// (same unit/denomination and same economic intent), not a different penalty path.
}Inverted ratio + bp-precision truncation: LP shares can round to zero (Medium/Aptos)
Pattern: (1) wrong fraction direction, then (2) quantize to basis points so small shares become 0.
// Context: LP-share math has TWO bugs stacked. (1) The fraction is inverted
// (pool_total / deposit instead of deposit / pool_total). (2) Even with the
// operands fixed, quantizing the share to basis points (1 part in 10,000)
// loses any deposit smaller than 1 bp of the pool, so small depositors
// mint 0 LP tokens for real money.
//
// Correct proportion: deposit / pool_total.
// VULNERABLE:
// Bug 1: INVERTED RATIO (pool_total / deposit).
// Example: deposit=1,000; pool_total=100,000.
// Correct: 1,000/100,000 = 1%.
// Actual: 100,000/1,000 = 100x (as a "share").
let share_proportion_bps = math64::mul_div(
*vector::borrow(&asset_amounts, 0), // pool_total
BPS_BASE, // 10,000
*vector::borrow(&deposit_amounts, 0) // deposit
);
// Bug 2: even if the operands were swapped, using BPS loses sub-1bp deposits.
// Anything with true_share < 1 bp truncates to 0 => depositor gets 0 LP tokens.
let depositor_lp_token_share = math64::mul_div(
lp_token_amount,
share_proportion_bps,
BPS_BASE,
);
// SAFE:
// - Fix ratio direction.
// - Use higher precision than BPS for shares (e.g., 1e18), or keep fraction form.
let share_proportion_hi = math64::mul_div(deposit, SHARE_PRECISION, pool_total);
assert!(share_proportion_hi > 0, E_DEPOSIT_TOO_SMALL); // or enforce min deposit
let lp_out = math64::mul_div(lp_token_amount, share_proportion_hi, SHARE_PRECISION);Unit mismatch: duration constant compared to token amount (Low/Sui)
Pattern: values with different “units” (time vs. tokens) are compared because both are u64.
// Context: both `claimable` (token base units) and `DURATION` (seconds) are
// `u64`, so the type checker doesn't notice the dimensional nonsense. The
// guard `claimable > DURATION` rejects every reward smaller than 604,800
// base units, a number that has nothing to do with token economics.
// DURATION is time (seconds).
const DURATION: u64 = 7 * 86400; // 604,800 seconds
fun extract_claimable_for<T>(self: &mut Voter<T>, gauger_id: ID): Balance<T> {
let gauger_id = into_gauge_id(gauger_id);
self.update_for_internal<T>(gauger_id);
let claimable = *self.claimable.borrow(gauger_id);
// VULNERABLE:
// claimable is a token amount (base units), not time.
// Comparing token units to seconds is dimensionally meaningless.
// Effect: if claimable <= 604,800 base units, user can never claim (DoS by dust).
assert!(claimable > DURATION);
// SAFE (conceptual): assert against a token-denominated threshold, or remove.
// Example: enforce a minimum claim size in token base units.
assert!(claimable > MIN_CLAIMABLE_TOKENS, E_TOO_SMALL_TO_CLAIM);
// [...]
}2.3 Dust, Rounding Accumulation, and Share Inflation Attacks
Rounding attacks exploit the gap between what a user deposits and what integer math credits them. The simplest version is dust accumulation: many small interactions, each rounding the attacker's way, compounding into real value over time. The dangerous version is the first-depositor share inflation attack, where the attacker manipulates a vault's share price so that later depositors receive zero shares for real money.
Share inflation in Move vaults
Any Move protocol using the familiar shares = deposit * total_shares / total_assets pattern inherits the same inflation attack that has repeatedly hit ERC-4626 vaults on Ethereum. The attacker deposits 1 token and receives 1 share, then donates a large amount directly to the vault balance, which inflates total_assets without minting new shares. The next depositor's share calculation is deposit * 1 / (large_donated_amount), which truncates to 0. The attacker then redeems their single share and walks away with the victim's deposit. MixBytes' analysis and OpenZeppelin's ERC-4626 audit walk through the pattern in detail, and the MVD v3.0 lists inflation attacks as a recurring finding class in Move audit reports.
Although this is a well-known issue that mainly affects new vaults, protocols usually mitigate it by raising the cost of this attack during the vault’s bootstrap phase and enforcing a minimum token or share balance.
// VULNERABLE: First depositor can inflate share price
public entry fun deposit(user: &signer, pool: &mut Pool, amount: u64) {
let shares = if (pool.total_shares == 0) {
amount // First depositor: 1:1 ratio
} else {
amount * pool.total_shares / balance::value(&pool.assets)
// After donation: 1000 * 1 / 1000001 = 0 shares for victim
};
pool.total_shares = pool.total_shares + shares;
balance::join(&mut pool.assets, take_payment(user, amount));
}
// SAFE: Dead shares + minimum deposit
public entry fun deposit(user: &signer, pool: &mut Pool, amount: u64) {
assert!(amount >= MIN_DEPOSIT, E_TOO_SMALL);
let shares = if (pool.total_shares == 0) {
// Burn dead shares to prevent empty-pool manipulation
let dead_shares = DEAD_SHARES; // e.g., 1000
pool.total_shares = pool.total_shares + dead_shares;
amount - dead_shares
} else {
amount * pool.total_shares / balance::value(&pool.assets)
};
assert!(shares > 0, E_ZERO_SHARES);
pool.total_shares = pool.total_shares + shares;
balance::join(&mut pool.assets, take_payment(user, amount));
}
Zero-share/div-by-zero: liveness DoS (High/Sui)
Pattern: same first-depositor inflation shape, but on a staking exchange rate, and the failure mode is liveness, not theft.
// Context: same first-depositor inflation shape as a vault, but on a Walrus
// staking pool's exchange rate. If `share_amount` is ever drained to 0,
// `(amount * wal_amount) / share_amount` divides by zero and aborts every
// call. Because the staking pool participates in epoch advancement, that
// single dust attack halts chain progression (liveness, not theft).
public(package) fun convert_to_wal_amount(exchange_rate: &PoolExchangeRate, amount: u64): u64 {
match (exchange_rate) {
PoolExchangeRate::Flat => amount,
PoolExchangeRate::Variable { wal_amount, share_amount } => {
let amount = (amount as u128);
// VULNERABLE: if `share_amount` ever reaches 0, this divides by zero
// and aborts. In a staking pool that participates in epoch advancement,
// a single dust attack that empties shares bricks the chain's progression.
let res = (amount * *wal_amount) / *share_amount;
res as u64
},
}
}
// SAFE (conceptual): permanent dust floor so `share_amount > 0` is invariant.
// - Mint dead shares at pool creation that can never be burned, OR
// - Reject the operation that would zero out share_amount.Rounding "kicker": floor-to-1 becomes extractable bias (Low/Aptos)
Pattern: a defensive "round up to 1" rule combined with an unbounded small-end input lets dust calls farm one free share each.
// Context: `(amount * ratio) / PRECISION` is the standard integer-math way
// to convert an underlying `amount` into shares; `ratio` is the share
// price pre-multiplied by `PRECISION` so the divide brings the result back
// to share units. A "fairness" patch tries to protect users whose deposit
// truncates to 0 shares by handing them 1 share. The hole: `amount` has no
// minimum, and when 1 share is worth far more than 1 base unit of
// underlying, that 1 share is essentially free.
// Walkthrough (pool: 1,000,000 underlying / 1,000 shares, so 1 share ≈ 1,000
// underlying; ratio ≈ PRECISION / 1,000):
// 1. Attacker calls from_shares(ratio, 1).
// 2. shares = 1 * (PRECISION / 1,000) / PRECISION --> truncates to 0.
// 3. Floor-to-1 fires: shares = 1. Cost: 1 base unit. Redeem value: ~1,000.
// 4. Repeat. One ~free share per call. Drains the pool.
// VULNERABLE:
public fun from_shares(ratio: u256, amount: u64): u64 {
let shares = (amount as u256) * ratio / PRECISION;
assert!(shares <= (U64_MAX as u256), E_U64_OVERFLOW);
// BUG: unbounded floor-to-1. Adversarial dust mints 1 share for ~free.
if (amount > 0 && shares == 0) { shares = 1 };
(shares as u64)
}
// SAFE: reject the round-to-zero case instead of promoting it. Users with
// legitimate dust must batch off-chain into a single larger deposit.
public fun from_shares(ratio: u256, amount: u64): u64 {
let shares = (amount as u256) * ratio / PRECISION;
assert!(shares <= (U64_MAX as u256), E_U64_OVERFLOW);
assert!(shares > 0, E_AMOUNT_TOO_SMALL); // no silent kicker
(shares as u64)
}Round-up bias: new LPs overpay in dust (Medium/Sui)
Pattern: safe_mul_div_up for required-deposit math silently taxes new participants in favor of existing holders.
// Context: an LP wants to deposit `max_a` of token A into a pool with
// current reserves (reserve_a, reserve_b). To preserve the pool's price,
// the LP must also deposit token B at the exact current ratio:
//
// b_star = max_a * reserve_b / reserve_a
//
// Because Move integer division truncates, the protocol has to pick a
// rounding direction whenever this product is not exact. That choice
// decides who absorbs the sub-unit dust: the depositor or the pool.
// VULNERABLE: rounds UP. If the true ratio works out to 999.4 base units of
// token B, `safe_mul_div_up` returns 1000, so the LP pays 1 extra base unit
// per deposit. The extra unit stays inside the pool (i.e., accrues to
// existing LP holders). Spread across many deposits, this is a silent tax
// on new participants in favor of incumbents.
let b_star = safe_mul_div_up(max_a, reserve_b, reserve_a);
// SAFE: round DOWN for the user's *required* input (999 instead of 1000).
// The pool then verifies the resulting ratio is within an acceptable
// slippage band before accepting the deposit.
//
// Rule of thumb: protocol wins on outputs (payouts, fees collected),
// user wins on inputs (required deposits). Either flip leaks value.
let b_star = safe_mul_div_down(max_a, reserve_b, reserve_a);2.4 Silent Narrowing: Downcasts and the Cast-to-u64 Trap
Move's VM aborts on +/-/* overflow, but arithmetic bugs still cluster around type boundaries. In modern Move (including both Aptos and Sui), integer casts abort if the value is out of range (they do not truncate). That said, downcasts are still a common audit hotspot because developers (a) rely on assumptions that later stop holding when pool sizes grow, and (b) perform "checked" conversions incorrectly in custom numeric wrappers.
// SAFE: VM aborts on these overflows
let a: u64 = MAX_U64;
let b = a + 1; // VM aborts -- overflow detected
let c = a * 2; // VM aborts -- overflow detected
// DANGEROUS: Custom numeric wrappers can reintroduce "narrowing" at boundaries
// (for example, by dropping high bits during serialization or by using an
// incorrect range check before a cast).
// For custom u128/u256 bit operations, always validate before shifting:
assert!(value >> (64 - shift_amount) == 0, E_WOULD_OVERFLOW); // Check high bits
let result = value << shift_amount; // Now safe
// For downcasts, validate that the value fits:
assert!(big <= (U64_MAX as u128), E_U64_OVERFLOW);
let narrowed = big as u64;Overflow + div-by-zero in governance loop: epoch DoS (Medium/Sui)
Pattern: the same line of code has two opposite failure modes: overflow at the high end, div-by-zero at the low end. Both halt epoch advancement.
// Context: the validator-committee loop tallies `(capacity * n_shards) / weight`
// for every node. One line, two opposite failure modes: overflow if any
// node sets a huge capacity, div-by-zero if a node's stake yields zero
// shards. Either abort halts epoch advancement for the whole network.
public(package) fun select_committee_and_calculate_votes(self: &mut StakingInnerV1) {
[...]
node_ids.length().do!(|idx| {
[...]
// VULNERABLE: two failure modes on one line.
// 1. node_capacity huge => (capacity * n_shards) overflows u64 => abort.
// A malicious node sets a giant capacity to brick this loop.
// 2. weight == 0 (stake too low for any shards) => div-by-zero => abort.
let capacity_vote = (pool.node_capacity() * (self.n_shards as u64)) / weight;
capacity_votes.insert(capacity_vote, weight);
});
[...]
}
// SAFE (conceptual):
// - Promote to u128 for the multiply: ((cap as u128) * (n_shards as u128)) / weight.
// - Guard the divisor: assert!(weight > 0, E_NO_SHARDS); or skip the node.
// - Bound node_capacity at registration so adversarial values cannot enter the loop.Counter type exceeds bitmap capacity: index corruption (Info/Sui)
Pattern: the counter type's max (u8 = 255) is larger than the data structure it indexes (128-bit bitmap). The VM's overflow guard fires too late.
// Context: the registry counter is `u8` (max 255), but the bitmap it indexes
// holds only 128 slots. The VM only aborts when the counter wraps at 255,
// but indexes 128..254 silently land out-of-range against the bitmap and
// corrupt downstream state long before that abort ever fires. The relevant
// bound is the *consumer's* range, not the integer type's range.
// VULNERABLE:
public fun next_id(registry: &mut TransceiverRegistry): u8 {
let id = registry.next_id;
registry.next_id = id + 1; // VM only aborts at id == 255, not at id == 128.
id
}
// SAFE: bound the counter at the actual data-structure capacity.
public fun next_id(registry: &mut TransceiverRegistry): u8 {
let id = registry.next_id;
assert!((id as u64) < BITMAP_CAPACITY, E_REGISTRY_FULL); // 128, not 256
registry.next_id = id + 1;
id
}Round-up helper overflow: (a + b - 1) can overflow (Info/Sui)
Pattern: the standard ceiling-division trick adds two values before dividing, and that intermediate addition can overflow.
// Context: the textbook ceiling-division trick `(a + b - 1) / b` materializes
// the sum `a + b - 1` BEFORE the divide. For values near `u128::MAX`, that
// intermediate addition aborts on overflow even though the true ceiling
// result would fit comfortably in `u128`.
// VULNERABLE: classic ceiling-division helper.
fun div_up(a: u128, b: u128): u128 {
(a + b - 1) / b // overflow if a + b - 1 > u128::MAX
}
// SAFE: split into floor + fix-up so no oversized intermediate exists.
fun div_up(a: u128, b: u128): u128 {
let q = a / b;
if (a % b == 0) q else q + 1
}Lifecycle underflow: revoked withdraw permanently aborts (Medium/Aptos)
Pattern: Move aborts on subtraction underflow (unlike Solidity's silent wrap), so a state-lifecycle bug becomes a permanent abort.
// Context: the math relies on the implicit invariant `withdrawn <= vested_amount`.
// Revocation reduces `vested_amount`, which can flip the invariant. Move
// aborts on subtraction underflow (unlike Solidity's silent wrap), so a
// state-lifecycle edge case becomes a permanent abort: every future
// `claim` call dies on the same subtraction line.
// VULNERABLE: every future call aborts on the subtraction.
let claimable = vested_amount - withdrawn;
// SAFE: saturate at zero. The invariant about which is larger now lives
// in the math, not in the implicit state-machine assumption.
let claimable = if (vested_amount > withdrawn) { vested_amount - withdrawn } else { 0 };Case Study 1: Cetus Protocol (~$223M, May 2025)
The Cetus exploit is the most expensive Move vulnerability ever and the canonical case study for Part 3.
What happened. Cetus AMM relied on the integer-mate library for its concentrated-liquidity math. That library's checked_shlw (checked shift-left wide) function was supposed to guard against 192-bit overflow, but the guard itself was wrong: a miscalibrated threshold let certain large inputs pass a check that should have aborted. An attacker opened a narrow position with tick range [300000, 300200], supplied an extreme liquidity value, and watched a deposit of one token get credited as enormous liquidity. Of the ~$223M drained, Sui validators froze roughly $162M on-chain; the remaining ~$60M had already bridged to Ethereum. (Halborn | Cyfrin | SlowMist)
Attack sequence:

Why all three audits missed it. Dedaub's technical analysis shows the bug was introduced, fixed, and then re-introduced in a later version. Nefture's post-mortem confirms all three audits (OtterSec, MoveBit, Zellic; see Zellic's separate informational March-April 2025 review which post-dates the bug) missed it because integer-mate sat just outside their scope. The library was treated as a black box because Move's VM "handles overflow", but Move's VM does not handle left-shift overflow.
Lessons for auditors:
- Math libraries, especially anything involving wide integers or bit shifts, deserve the same scrutiny as the protocol that calls them.
- Utility libraries are not black boxes when the protocol's solvency flows through them.
- Move's overflow protection is not uniform:
<<,as-narrowing, and integer division are exceptions. Every custom check around those operations must be correct on its own. - Bug fixes must be tracked across versions. Cetus's
checked_shlwwas re-introduced after being fixed.
Case Study 2: Thala LPT (u128 → u64 Downcast on StableSwap Invariant)
The Cetus pattern is not unique. Zellic caught a near-miss critical in Thala's LPT/xLPT codebase before deployment; different operation, same class of failure.
What happened. Thala's Oracle.move calls stable_math::compute_invariant(&balances, amp) to compute the StableSwap "D" invariant (a u128). The result is then cast as u64 to feed downstream price logic. From Zellic's report:
"The stable_swap invariant calculation in Oracle.move involves downcasting from u128 to u64, which could potentially lead to integer overflow in certain scenarios."
D grows roughly with amp × n × balance, so high-amp pools or large balances can push D above 2^64 − 1. The silent downcast produces a wrong invariant; every downstream price/oracle calculation is corrupted.

Cetus exploited bit-shift overflow; Thala (would have) exploited downcast overflow. Both are operations Move's VM does not catch. Both happen at the boundary between a wide-integer math layer and a narrower consumer. Both are the kind of cross-layer cast that gets approved on visual inspection because "overflow can't happen, the VM checks." The fix is one assertion: assert!(d <= (U64_MAX as u128), E_INVARIANT_OVERFLOW); d as u64.
This case study is short on exploit prose because Zellic caught it pre-deployment. That is the entire point. The Cetus pattern is detectable; it just needs to be looked for.
Arithmetic Vulnerability Decision Tree
Use this decision tree when reviewing any Move function that performs arithmetic on user-supplied or pool-derived values:

3. Economic & Type-System Vulnerabilities
The previous section covered arithmetic mistakes whose root cause is a single operation. This section covers vulnerabilities where the math itself is correct, but the boundary between math and identity, type, or external state is broken.
3.1 Generics Type Checking Failures
Zellic's Top 10 Aptos Move Bugs ranks this as the single most common bug class in its audit corpus, and the Aptos Security Guidelines §Generics type check document the same pattern. Generic type parameters are user-controlled input. If a function doesn't validate them against the types it actually stored, a caller can swap them for something profitable. The snippets below are adapted from Zellic's intentionally vulnerable demo protocol, DonkeySwap.
// VULNERABLE: No check that BaseCoinType matches what was deposited
// Attacker deposits USDC, creates an order, then cancels with ZEL, receives ZEL
public fun cancel_order<BaseCoinType>(user: &signer, order_id: u64) acquires OrderStore {
let order = get_order(order_id);
// VULNERABLE: missing assert!(order.base_type == type_info::type_of<BaseCoinType>())
deposit_funds<BaseCoinType>(order_store, signer::address_of(user), order.base);
}
// SAFE: Validate generic type matches stored type
public fun cancel_order<BaseCoinType>(user: &signer, order_id: u64) acquires OrderStore {
let order = get_order(order_id);
assert!(
order.base_type == type_info::type_of<BaseCoinType>(),
ERR_ORDER_WRONG_COIN_TYPE
);
assert!(order.user_address == signer::address_of(user), ERR_PERMISSION_DENIED);
deposit_funds<BaseCoinType>(order_store, signer::address_of(user), order.base);
}Wrong-pool liquidation: generic SupplyPool<X, SX> not asserted (High/Sui)
Pattern: liquidation accepts a generic SupplyPool<X, SX> without asserting SX matches the position's debt-share type. The mismatch turns repayment into a no-op, while the rewards leg still pays out collateral.
// Context: `liquidate_col_y` takes a `SupplyPool<$X, $SX>` parameter, where
// `$SX` is the share-token type that the supply pool issues for debt
// accounting. The function pulls *all* debt shares out of the position via
// `take_all()` and feeds them into `supply_pool.repay_max_possible(...)`.
// Because `$SX` is never asserted to match the share type stored on the
// position, an attacker can pass a sibling `SupplyPool<$X, $SX'>` whose
// share type is foreign. `repay_max_possible` then finds zero matching
// shares to burn, returns `x_repaid = 0`, and the call short-circuits as a
// no-op for the debt leg. The reward/seize leg further down still transfers
// collateral, so the liquidator walks away with `Balance<$Y>` for free.
// VULNERABLE: no assertion that `$SX` matches the position's debt-share type.
public(package) macro fun liquidate_col_y<$X, $Y, $SX, $LP>(
$position: &mut Position<$X, $Y, $LP>,
$config: &PositionConfig,
$price_info: &PythPriceInfo,
$debt_info: &DebtInfo,
$repayment: &mut Balance<$X>,
$supply_pool: &mut SupplyPool<$X, $SX>,
$clock: &Clock,
): Balance<$Y> {
[...]
let mut debt_shares = position.debt_bag_mut().take_all();
// BUG: $SX may not match the share type stored on $position.
// repay_max_possible then burns 0 shares, x_repaid == 0, debt is untouched.
let (_, x_repaid) = supply_pool.repay_max_possible(&mut debt_shares, &mut r, $clock);
[...]
}
// SAFE: assert the position's debt-share type matches the supply pool's `$SX`
// before any repayment math runs.
public(package) macro fun liquidate_col_y<$X, $Y, $SX, $LP>(
$position: &mut Position<$X, $Y, $LP>,
/* ... */
$supply_pool: &mut SupplyPool<$X, $SX>,
$clock: &Clock,
): Balance<$Y> {
assert!(
$position.debt_share_type() == type_name::get<$SX>(),
E_WRONG_SUPPLY_POOL
);
/* ... */
}Wrong positional generic order in flashloan call (Suggestion/Aptos)
Pattern: flashloan<A, B, ...> borrows the first generic. Caller meant to liquidate using B but listed A first; the type system accepts the argument list and silently borrows the wrong asset.
// Context: Thala's `stable_pool::flashloan<A, B, C, D>` borrows the asset
// in whichever generic position has a non-zero amount, and the loan is
// returned typed as the *first* generic. The liquidation helper wants to
// borrow `AptosCoin` to repay a debt, then seize THL collateral. The
// author listed `ThalaAPT` first and `AptosCoin` second, but the returned
// `loan` is bound to the first slot. Type checking passes because both
// types are valid generics; the function silently produces a
// `Coin<ThalaAPT>` instead of `Coin<AptosCoin>`, and the rest of the
// liquidation operates on the wrong asset.
// VULNERABLE: borrows ThalaAPT (first generic), but caller meant AptosCoin.
let (zero_0, loan, zero_2, zero_3, flashloan) = stable_pool::flashloan<ThalaAPT, AptosCoin,
Null, Null>(
repay_amount, 0, 0, 0
);
// `loan: Coin<ThalaAPT>`, not Coin<AptosCoin>. Downstream liquidate() now
// repays the wrong debt leg and produces an inconsistent position.
let seized = liquidate<THL, AptosCoin>(account, vault_address, loan, min_shares_out);
// SAFE: place the asset to be borrowed in the FIRST generic slot, with a
// non-zero amount only on that slot. Better still, expose a non-positional
// API (e.g. `flashloan_of<T>(amount)`) so the type witness, not the
// argument index, decides which asset is borrowed.
let (loan, flashloan) = stable_pool::flashloan_of<AptosCoin>(repay_amount);
let seized = liquidate<THL, AptosCoin>(account, vault_address, loan, min_shares_out);3.2 Token Identifier Collision & Dynamic-Field Key Collisions
A closely related family of generics-style bugs centers on what identifier you key by. If a protocol uses human-readable symbols, user-supplied IDs, or untyped hashes as keys into a table or dynamic field, attackers can engineer collisions. The Aptos guidelines call this out as Token Identifier Collision:
// VULNERABLE: "TOKEN-AB" + "C" = "TOKEN-A" + "BC" gives the same pool seed for different tokens
public fun get_pool_address(token_1: Object<Metadata>, token_2: Object<Metadata>): address {
let seed = b"LP-";
vector::append(&mut seed, *string::bytes(&fungible_asset::symbol(token_1)));
vector::push_back(&mut seed, b'-');
vector::append(&mut seed, *string::bytes(&fungible_asset::symbol(token_2)));
object::create_object_address(&@swap, seed) // Collision possible!
}
// SAFE: Use unique object addresses as seeds, no collision possible
public fun get_pool_address(token_1: Object<Metadata>, token_2: Object<Metadata>): address {
let seeds = vector[];
vector::append(&mut seeds, bcs::to_bytes(&object::object_address(&token_1)));
vector::append(&mut seeds, bcs::to_bytes(&object::object_address(&token_2)));
object::create_object_address(&@swap, seeds)
}Dynamic-field allocation_id collision (High/Sui)
Pattern: a dynamic-object-field is keyed only by allocation_id, not by the unique merkle leaf hash. Two distinct leaves sharing the same allocation_id collapse into one DistributionState; the first beneficiary to call withdraw permanently captures every other allocation under that ID.
// Context: Magna Airlock represents each vesting allocation as a merkle
// leaf and stores per-allocation runtime state (withdrawn amounts,
// termination timestamps) in a dynamic-object-field hung off `vesting_uid`.
// The DOF key is the user-supplied `allocation_id`, but `allocation_id` is
// not unique across leaves: distinct leaves (different beneficiary, amount,
// or schedule) can share the same id. When two leaves share an id, the
// second `ofield::add` either aborts the second registration or silently
// reuses the first leaf's `DistributionState`. The first beneficiary to
// call `withdraw` then drains the merged accounting and locks every other
// claimant out, with no way to recover (merkle roots cannot be removed).
// VULNERABLE: keyed only by allocation_id, not by leaf hash.
ofield::add(
vesting_uid,
allocation.allocation_id, // BUG: not unique per leaf.
DistributionState {
beneficiary: allocation.original_beneficiary,
withdrawn: 0,
terminated_timestamp: 0,
},
);
// SAFE: key by the merkle leaf hash so two distinct leaves cannot collide.
// `leaf_hash` mixes every field that distinguishes one allocation from
// another, so a collision implies a true merkle duplicate (which is
// rejected upstream during root construction).
ofield::add(
vesting_uid,
leaf_hash, // hash(allocation_id || beneficiary || amount || schedule || ...)
DistributionState { /* ... */ },
);Same-asset pair allowed: self-collateral borrow (Suggestion/Aptos)
Pattern: nothing prevents a Pair from having the same Coin/FungibleAsset for both liability and collateral, undermining the collateralization invariant. A user can borrow against their own deposit risk-free.
// Context: a Pair<L, C> assigns liability asset L and collateral asset C.
// Nothing prevents L == C, so a user can deposit X as collateral and borrow
// X against it, sidestepping the collateralization invariant entirely.
// Liquidation never triggers because seized collateral exactly cancels
// the debt in the same unit.
// VULNERABLE: no liability != collateral check at pair creation.
public fun create_pair<L, C>(/* ... */): Pair<L, C> {
Pair {
liability_type: type_of<L>(),
collateral_type: type_of<C>(),
/* ... */
}
}
// SAFE: assert distinct asset types.
public fun create_pair<L, C>(/* ... */): Pair<L, C> {
assert!(type_of<L>() != type_of<C>(), E_SAME_ASSET_PAIR);
Pair { /* ... */ }
}
// A short asserter assert!(liability_type != collateral_type) is a one-liner
// that closes a whole class of economic attacks.Permissionless pool lot-size collisions (Suggestion/Sui)
Pattern: a factory exposes lot_size/tick_size to the pool creator. Adversarial choices make distinct base quantities round to the same quote quantity, colliding on the price-quantity map.
// Context: pool creators choose lot_size, tick_size, and min_size freely.
// With adversarial parameters, two distinct base quantities collapse to
// the same quote quantity, and the price-quantity map then keys on the
// collided value and orders that should be separate overwrite each other.
// VULNERABLE: factory accepts any combination.
public fun create_pool<B, Q>(
lot_size: u64, // e.g. 1
tick_size: u64,
min_size: u64, // e.g. 1000
/* ... */
): Pool<B, Q> { /* ... */ }
// Collision example with lot_size = 1, min_size = 1000:
// base_qty = 1000 -> quote_qty rounds to k
// base_qty = 1999 -> quote_qty rounds to k (same bucket)
// SAFE (conceptual): enforce a granularity invariant so distinct base
// quantities cannot share a quote bucket.
assert!(min_size >= MIN_SIZE_FLOOR, E_BAD_PARAMS);
assert!(min_size % lot_size == 0, E_BAD_PARAMS);
assert!(quotes_distinct(lot_size, tick_size, min_size), E_BAD_PARAMS);3.3 Price/Rate Invariant Violations
Price manipulation works when a protocol derives valuations from data an attacker can influence inside the same transaction or block. In Move DeFi, this usually means pool ratios, current balances, or spot prices that a flash-borrowed deposit has just distorted. The Typus Finance exploit ($3.44M, October 2025) is the cleanest recent example: the oracle update function had no authorization, so the attacker simply wrote an arbitrary price and swapped against it. Authorization alone is not enough, though. Any protocol that reads a spot AMM price for collateral valuation, liquidation thresholds, or swap routing is exposed to single-transaction manipulation.
Zellic ranks price oracle manipulation as the #4 most common bug in its Aptos Move corpus, and the pattern shows up again in the Dola Protocol audit by MoveBit. On Aptos specifically, the Fungible Asset migration (2024 to 2025) created a transitional window in which a protocol that checked only coin::balance could miss the same asset's fungible_asset balance and under-collateralize positions as a result.
// VULNERABLE: Spot price from pool ratio, manipulable via flash loan
public fun get_price(pool: &Pool): u64 {
balance::value(&pool.quote_balance) / balance::value(&pool.base_balance)
// Attacker can deposit/withdraw to shift this ratio in the same PTB
}
// VULNERABLE: Oracle price consumed without staleness check
public fun get_collateral_value(oracle: &Oracle, amount: u64): u64 {
amount * oracle.price / PRECISION
// oracle.price could be hours old during volatile markets
}
// SAFE: TWAP + staleness + deviation check
public fun get_price_safe(oracle: &Oracle, clock: &Clock): u64 {
let current_time = clock::timestamp_ms(clock);
assert!(current_time - oracle.last_update_ms <= MAX_STALENESS_MS, E_STALE_PRICE);
let deviation = if (oracle.price > oracle.twap_price) {
oracle.price - oracle.twap_price
} else {
oracle.twap_price - oracle.price
};
assert!(deviation * 10000 / oracle.twap_price <= MAX_DEVIATION_BPS, E_PRICE_DEVIATION);
oracle.twap_price // Use TWAP, not spot
}Undervalued oracle: safe on collateral, toxic on borrow (Medium/Aptos)
Pattern: an asset oracle deliberately biased "conservative" on the collateral side becomes a discount on the borrow side. Same number, opposite economic meaning depending on which leg consumes it.
// Context: stLPT's oracle is intentionally biased downward to be safe on
// the *collateral* side (don't lend too much against it). The same oracle
// is then reused on the *borrow* side, where a low price means a discount:
// the attacker borrows stLPT cheap and unwinds at true market value.
// Direction of bias must depend on which leg consumes the price.
// VULNERABLE: same `price()` returned for both legs.
public fun price(asset: &Asset): u64 {
asset.conservative_low_price // safe for collateral, toxic for borrow
}
let collateral_value = amount * price(&stlpt) / PRECISION; // OK, undervalues
let borrow_cost = amount * price(&stlpt) / PRECISION; // BUG: also undervalues
// => cheap borrow
// SAFE: separate oracle reads per leg, with role-aware bias.
public fun price_for_collateral(asset: &Asset): u64 { asset.conservative_low_price }
public fun price_for_borrow(asset: &Asset): u64 { asset.conservative_high_price }Newest-price selection skips confidence gate (Suggestion/Sui)
Pattern: oracle selection combines validity, recency, and confidence into one short-circuited if. The newest sample wins regardless of confidence, and an older high-confidence price is silently filtered out.
// Context: oracle samples carry (timestamp, price, confidence). The selector
// wants "newest sample whose confidence is acceptable", but combines all
// three checks into one short-circuited expression. The recency leg fires
// first, so a newer-but-low-confidence sample updates max_timestamp_ms
// and silently filters out an older trustworthy price.
// VULNERABLE: one combined gate.
if (is_valid(ts) && ts > max_timestamp_ms && confidence_ok(conf)) {
max_timestamp_ms = ts; // BUG: timestamp wins even when confidence fails,
selected_price = p; // because && short-circuits left-to-right.
}
// SAFE: independent gates, in the right order.
if (!is_valid(ts)) continue;
if (!confidence_ok(conf)) continue; // confidence first
if (ts <= max_timestamp_ms) continue; // then recency
max_timestamp_ms = ts;
selected_price = p;3.4 Cross-Chain Encoding & Modulo Mistakes
Cross-chain payload decoding is a fertile source of arithmetic-adjacent bugs in Move because from_bcs::* is not a generic ABI decoder. Solidity packs integers in big-endian; BCS (Aptos's serialization format) is little-endian. A protocol that decodes a Solidity-side payload byte-for-byte with from_bcs reads every multi-byte integer wrong.
BCS little-endian decoding of Solidity big-endian payload (High/Aptos)
Pattern: a cross-chain payload encoded by Solidity (big-endian) is decoded byte-for-byte with from_bcs (little-endian). Every multi-byte integer is read with reversed byte order; nonces and amounts wrap to astronomical values.
// Context: `message` is a Solidity-side ABI-packed payload (big-endian).
// `from_bcs::*` deserializes BCS, which is little-endian. Decoding the same
// bytes with the wrong endianness flips every multi-byte integer.
//
// Example: ccdm_nonce = 1 on Solidity is 0x00..01 (32 bytes, big-endian).
// `from_bcs::to_u256` reads those bytes as 0x01..00 = 1 << 248.
// VULNERABLE: byte-order mismatch.
let market_hash = from_bcs::to_address(vector::slice(&message, 0, 32));
let ccdm_nonce = from_bcs::to_u256(vector::slice(&message, 32, 64));
let expected_messages = from_bcs::to_u8(vector::slice(&message, 64, 65));
// SAFE (conceptual): reverse each multi-byte integer slice before decoding,
// or use a Solidity-aware ABI decoder. `from_bcs` is not a generic ABI decoder.Tautologically-failing modulo assertion (High/Aptos)
Pattern: a length-check assertion compares len % 32 against 65, which is impossible since x % 32 < 32. Every call aborts on a payload that is otherwise well-formed.
// Context: payload is a 65-byte header followed by N × 32-byte depositor
// records. The intended check is "after the header, the remainder is a
// multiple of 32". The author wrote the wrong form.
// VULNERABLE: unreachable. `len % 32` is always in [0, 32), never 65.
assert!(vector::length(&message) % 32 == 65, ERR_DEPOSIT_MANAGER_MALFORMED_PAYLOAD);
// SAFE: subtract the header length first, then check divisibility.
let len = vector::length(&message);
assert!(len >= 65 && (len - 65) % 32 == 0, ERR_DEPOSIT_MANAGER_MALFORMED_PAYLOAD);Missing validation in BCS allocation deserialization (Medium/Sui)
Pattern: BCS-deserialized struct fields are accepted as-is, with no invariants asserted between fields. Mismatched lengths panic at runtime; zero counters cause division-by-zero downstream; sum-mismatch permanently locks funds.
// Context: Magna Airlock parses vesting allocations from BCS payloads.
// Three classes of bug all stem from "deserialize, then trust":
// - parallel arrays without a length-equality assert
// - period denominators without a > 0 assert
// - sum(unlock_amounts) != allocation.amount with no equality assert
// Because merkle roots cannot be removed, any malformed leaf bricks the
// allocation forever.
// VULNERABLE:
Schedule::Calendar {
unlock_timestamps: stream.peel_vec!(|stream| stream.peel_u32()), // Missing: length
unlock_amounts: stream.peel_vec!(|stream| stream.peel_u64()), // equality assert
}
Piece {
start_time: stream.peel_u32(),
period_length: stream.peel_u32(), // Missing: assert period_length > 0
number_of_periods: stream.peel_u32(), // Missing: assert number_of_periods > 0
amount: stream.peel_u64(),
}
// SAFE (conceptual): post-decode invariant block.
// assert!(vector::length(&unlock_timestamps) == vector::length(&unlock_amounts), E_LEN);
// assert!(period_length > 0 && number_of_periods > 0, E_ZERO);
// assert!(sum(&unlock_amounts) == allocation.amount, E_SUM);Modulo bias in pseudo-random u64_range (Suggestion/Aptos)
Pattern: random_u256 % range produces a non-uniform distribution when range does not divide 2^256 evenly. Tiny per-call bias compounds when the function drives selection logic (lottery, melee pool picks, MEV-style auctions).
// Context: Aptos's framework `randomness` API is unavailable during
// `init_module`, so teams hand-roll a selector. The straightforward
// `random % range` introduces a bias of (2^256 mod range) / 2^256:
// negligible per draw for small ranges, exploitable when the selector
// is called at scale.
// VULNERABLE: biased.
fun u64_range(low: u64, high: u64): u64 {
let r = random_u256();
let range = (high - low) as u256;
((r % range) as u64) + low
}
// SAFE: rejection sampling. Discard draws in the biased remainder window.
fun u64_range(low: u64, high: u64): u64 {
let range = (high - low) as u256;
let limit = (U256_MAX / range) * range; // largest multiple of range <= U256_MAX
loop {
let r = random_u256();
if (r < limit) { return ((r % range) as u64) + low }
}
}Full Checklist
The checklist below groups items by the question each one forces an auditor to answer. No static list covers every arithmetic path in every protocol; treat this as the floor of review, not the ceiling.
Bit-shift, downcast & VM-protection gaps
- [ ] All
<<(left-shift) operations validate inputs, with no silent overflow (Move's VM does NOT protect these) - [ ] All
as u64/as u32/as u8downcasts assert the source value fits the target type before the cast (per Thala LPT Zellic 3.1) - [ ] Custom math libraries (especially u256 / wide-integer) reviewed with same rigor as core logic (per Cetus exploit, May 2025)
- [ ] Custom signed-integer encodings (sign-magnitude, two's-complement-emulated): does the overflow check correctly distinguish overflow from sign-bit set? (per Aftermath Perps OS-AMP-ADV-02)
- [ ] Manual i192/i256 sign-bit decoding compares against position 191/255, not 192/256 (per Thala Oracle OS-TCO-SUG-00)
- [ ] All external math library dependencies are explicitly in audit scope
- [ ] Library bug fixes are tracked across versions; once-fixed-twice-broken is a real failure mode (per Cetus, where the bug was reintroduced)
Multiplication-division precision
- [ ]
a * b / cpatterns use u128 or u256 intermediate types to prevent overflow - [ ] Multiplication is performed before division (no intermediate truncation) (per Kuna Labs OS-KUL-ADV-01)
- [ ] Fee calculations cannot round to zero for any valid input; enforce minimum order sizes
- [ ] Rounding direction is explicitly chosen and safe for each computation
- [ ] Different branches of a fee/reward function are denominated in the same unit and at the same scale (per DeepBook OS-MDV-ADV-00)
- [ ] No threshold compares values in different units, e.g., a balance against a duration constant (per Magma Finance Zellic 3.2)
- [ ] Round-up helpers (
(a + b - 1) / b,safe_mul_div_up): can the intermediate overflow before the divide? (per Kuna Labs OS-KUL-SUG-06) - [ ] Liquidation/redemption math: does the rounding direction favor the protocol or the user? (per Aftermath Perps OS-AMP-SUG-02)
Share inflation, dust accumulation
- [ ] Vaults have first-depositor protection (dead shares, virtual offset, or minimum initial deposit) (per Solend STEAMM OS-SAM-ADV-00)
- [ ] Tokens cannot be donated directly to a vault balance without minting shares
- [ ] Minimum interaction amounts prevent dust accumulation attacks
- [ ] Defensive
floor-to-1rounding does not create an extractable kicker for arbitrarily small inputs (per Kofi Finance OS-KOF-ADV-06) - [ ] For staking exchange-rates: is
share_amount == 0reachable? If so, is downstream division-by-zero blocked? (per Mysten Walrus OS-MSW-ADV-01) - [ ] Round-up bias does not silently transfer value from new participants to existing ones (per Solend STEAMM OS-SAM-ADV-02)
- [ ] Splitting an accounting record (cost basis, attribution) preserves the sum invariant: child + child = original (per Aftermath MM v2 OS-AMM-ADV-03)
Generics & type checking
- [ ] Generic type parameters validated against stored
type_info::type_of<T>()in all functions - [ ] Privileged request structs are bound to the state's generic
T(phantom type or runtime type-name field) (per Matrixdock XAGm Zellic 3.1) - [ ] Sibling functions consuming the same
<X, Y, Z>combo all assert the type witness, with no asymmetry betweenliquidate_*/deleverage_*/withdraw_*(per Kuna Labs OS-KUL-ADV-00 + SUG-01) - [ ] Flashloan and similar APIs have non-positional or self-validating return types, so positional generics are not relied on (per Echelon OS-TEH-SUG-00)
Identifier & key collisions
- [ ] Pool/resource address seeds use unique object addresses, not human-readable strings
- [ ] Sui dynamic-field keys are content-derived hashes, not user-supplied IDs (per Magna Airlock Zellic 3.1)
- [ ] Pair / market creation asserts
liability_type != collateral_type(per Echelon OS-TEH-SUG-03) - [ ] Permissionless-factory parameters (
lot_size,tick_size) cannot produce collisions on derived state (per DeepBook OS-MDV-SUG-01)
Oracle & price invariants
- [ ] No price calculation uses manipulable spot price without TWAP
- [ ] Oracle prices have staleness checks
- [ ] Oracle confidence and recency are checked as independent gates, in the right order (per Aftermath Perps OS-AMP-SUG-01 #6)
- [ ] Direction of any conservative/aggressive oracle bias is correct for asset role (collateral vs borrow) (per Echelon Staked LPT OS-ESL-ADV-00)
- [ ] During the Aptos FA migration: are both
coin::balanceandfungible_assetbalances checked?
Encoding & cross-chain
- [ ] Cross-chain payloads decoded with
from_bcs::*are sourced from a chain that encodes in BCS (little-endian), not directly from Solidity ABI-packed bytes (per Echelon Zellic 3.1) - [ ] Modulo / divisibility assertions are reachable for valid input (
x % 32 == 65is unreachable for anyx) (per Echelon Zellic 3.2) - [ ] BCS deserialization of cross-chain payloads validates: (a) array-length consistency, (b)
period_length > 0, (c)sum(unlock_amounts) == allocation.amount(per Magna Airlock Zellic 3.3)
Economic & lifecycle
- [ ] No state-lifecycle transition (revocation, pause, snapshot) leaves a monotonic accumulator in a state where future subtraction underflows (per Magna OS-MGN-ADV-03)
- [ ] Custom signed types over unsigned
Balance: every call site ofto_balance/from_balanceis audited as part of one cluster, not in isolation (per Aftermath ifixed cluster, 4 findings: OS-AMM-ADV-02, -04, -06; OS-AMP-ADV-00) - [ ]
<=vs<in capacity/limit checks: ifpush_backfollows the assert, the bound is one over the intended max (per Aftermath MM v2 OS-AMM-SUG-00)
Audit process
- [ ] All deployed math libraries are in audit scope, even small utility libraries
- [ ] Custom signed-integer / fixed-point implementations receive priority review
- [ ] Property-based tests / Move Prover invariants cover boundary cases: zero, max, dust, division identities
Conclusion
Arithmetic sits in an awkward place in Move security. The VM's built-in overflow protection is genuinely good, and that is exactly the problem: it anchors developers on the operations the VM protects and draws attention away from the ones it doesn't. Bit shifts, downcasts, division truncation, and custom wide-integer math are where the real money has been lost. Cetus made the point with force. One incorrect comparison in a utility library cost more than every other Move vulnerability combined, and Zellic caught the next such bug at Thala before it shipped.
Every pattern in this article shares one feature: the type system cannot see it. Resource safety, ability constraints, and module encapsulation catch a lot, but none of them catch an off-by-one in a shift boundary, a fee that rounds to zero on small inputs, an as u64 cast on an oracle invariant, or a generic type the function never asserted against the stored value. Those bugs only surface through careful review, property-based testing, and formal verification of the arithmetic invariants the code actually depends on.
Up Next: Sui-Specific Attack Surface
Part 4 moves into the vulnerability classes unique to Sui's object-centric model: unintended object sharing, shared-object access control failures, dynamic field injection, Kiosk and TransferPolicy bypass patterns, currency standard pitfalls, and the contention DoS vectors that fall out of parallel execution.



