← Back to Blog

Field Order Is Sort Order — How Struct Layout Silently Breaks Ordered Maps in Move

Move compares structs lexicographically by field declaration order. If you use a struct as a key in an ordered map, the first field dominates sorting — not the field you think matters. A real bug from Decibel Exchange's perpetual futures DEX on Aptos.

✍ 0xTheBlackPanther 📅 23 February 2025 ⏱ 5 min read 🏷 Move, Aptos, Security

The Rule You Need to Know

In Move, when structs are used as keys in ordered data structures like BigOrderedMap, they're compared lexicographically by field declaration order. Not by any specific field. Not by whichever field seems most "important." The comparison starts at the first field and only moves to the next if there's a tie.

This is the same logic as string comparison — "apple" comes before "banana" because 'a' < 'b' at the first character. Move does this with struct fields.

The implication: If your struct key has three fields and you need the map sorted by the third field, you're out of luck. The first field dominates. The third field is only consulted when the first two are equal.

The Bug

This showed up in Decibel Exchange — a perpetual futures DEX on Aptos — during a security assessment by OtterSec (finding OS-DBX-ADV-07, Medium severity). The trading engine uses a TP/SL (take-profit/stop-loss) tracker that indexes orders by price, so it can quickly find which orders to trigger when the mark price moves.

The code uses BigOrderedMap with a struct key called PriceIndexKey. The logic assumes that borrow_front and borrow_back return the orders with the lowest and highest trigger_price — enabling fast lookups and early termination.

The problem? trigger_price wasn't the first field.

❌ BUGGY — trigger_price IS NOT FIRST struct PriceIndexKey {
  account: address, // ← sorts first!
  order_id: u64,
  trigger_price: u64, // ← meant to sort by this
}

Map sorted by: account → order_id → trigger_price
Price ordering: BROKEN
✅ FIXED — trigger_price IS FIRST struct PriceIndexKey {
  trigger_price: u64, // ← sorts first!
  account: address,
  order_id: u64,
}

Map sorted by: trigger_price → account → order_id
Price ordering: CORRECT

Why This Breaks

The functions take_ready_price_move_up_orders and take_ready_price_move_down_orders use borrow_front and borrow_back on the ordered map to peek at the next triggerable order. They assume price ordering, so they can bail early when the mark price no longer satisfies the trigger condition.

What happens with the wrong field order: 1. Map is sorted by account first, not trigger_price
2. borrow_front returns the entry with the lowest account address, not the lowest trigger price
3. Early termination logic checks this entry's price against mark price
4. May terminate early and skip valid triggerable orders that have higher account addresses
5. Or may fail to terminate and iterate unnecessarily through non-triggerable orders

The result: TP/SL orders that should trigger don't, and orders that shouldn't trigger might. The entire price-based indexing assumption collapses because the map was never sorted by price in the first place.

A Concrete Example

// Three orders in the map with the BUGGY struct layout // PriceIndexKey { account, order_id, trigger_price }

Entry A: { account: 0x1, order_id: 5, trigger_price: 150 }
Entry B: { account: 0x2, order_id: 1, trigger_price: 90 }
Entry C: { account: 0x3, order_id: 3, trigger_price: 100 }

// Sorted by account (first field), NOT by trigger_price:
// A (0x1, price=150) → B (0x2, price=90) → C (0x3, price=100)

// borrow_front() returns A (price=150)
// But the LOWEST trigger price is actually B (price=90)
// → Wrong order exposed. TP/SL logic breaks.
// Same three orders with the FIXED struct layout // PriceIndexKey { trigger_price, account, order_id }

Entry B: { trigger_price: 90, account: 0x2, order_id: 1 }
Entry C: { trigger_price: 100, account: 0x3, order_id: 3 }
Entry A: { trigger_price: 150, account: 0x1, order_id: 5 }

// Sorted by trigger_price (first field). Correct.
// borrow_front() returns B (price=90) — the actual lowest price
// → Correct order exposed. TP/SL logic works.

The Fix

Move trigger_price to the first field in the struct. That's it. The BigOrderedMap will now sort by price first, with account and order_id as tiebreakers — which is exactly the intended behavior.

Resolved in commit 487eb77.

The Bigger Pattern

This isn't specific to trading engines. Any Move code that uses ordered data structures with composite struct keys is vulnerable to this exact class of bug. The pattern is:

The bug pattern: 1. Developer creates a struct to use as a key in an ordered map
2. They mentally sort by one field (the "important" one)
3. But that field isn't the first field in the struct
4. Move's lexicographic comparison sorts by the actual first field
5. Every assumption about ordering is silently wrong

This is particularly dangerous because there's no compiler warning, no runtime error, and no abort. The map works perfectly — it's just sorted by the wrong criteria. Everything looks fine until you start getting incorrect results from queries that assume a specific ordering.

Why this is easy to miss: In languages like Rust, you'd implement Ord explicitly and control exactly how comparison works. In Move, struct comparison is implicit and determined entirely by field declaration order. There's no way to override it. The struct layout is the comparison logic.

Your Audit Checklist

  1. Every struct used as a map key — check field order. If the code assumes ordering by a specific field, that field must be declared first in the struct. No exceptions.
  2. Look for borrow_front / borrow_back assumptions. Any code that peeks at the front or back of an ordered map is assuming something about sort order. Verify that assumption against the actual struct layout.
  3. Grep for early termination logic on ordered iterators. If a loop breaks early based on a field comparison, that field better be the primary sort key. Otherwise the early exit is invalid.
  4. This is a silent bug. No abort, no error, no compiler warning. The map operates correctly by its own rules — it's just not sorted the way the developer thinks. You'll only catch this by reading the struct definition alongside the code that queries the map.
  5. Check this in any Move codebase, not just Aptos. Sui Move uses different data structures, but the same lexicographic comparison rules apply to any ordered collection where structs are used as keys.

In Move, struct field order isn't cosmetic — it's behavioral. Read every struct definition like it's a sorting specification. 🔍

Finding: OS-DBX-ADV-07 — Improper PriceIndexKey Ordering
Severity: Medium
Codebase: Decibel Exchange — Perpetual Futures DEX on Aptos
Auditor: OtterSec
Fix: Commit 487eb77
Bug Class: Incorrect ordering assumption on composite struct keys
Follow: @thepantherplus