← Back to Blog

How Perpetual Futures Work On-Chain — And How to Audit Them as a Security Researcher

Perp DEXes are among the most complex smart contract systems you'll ever audit. This post breaks down the core mechanics, where bugs hide, and what I learned auditing a fully on-chain perp exchange built in Move on Aptos.

✍ 0xTheBlackPanther 📅 Mar 2026 ⏱ 18 min read 🏷 Perps, Security, Move, Aptos

Why You Should Care About Perps

Perpetual futures (perps) are the largest segment of on-chain trading by volume. Protocols like Hyperliquid, dYdX, GMX, and newer entrants like Decibel on Aptos collectively handle billions in daily volume. They're also the most complex smart contract systems to audit — combining orderbook mechanics, oracle integrations, margin accounting, liquidation engines, and funding rate calculations in a single interconnected system.

If you're a security researcher looking to expand into DeFi auditing, perps are where the serious bugs live and where the serious bounties are. But you need to understand the mechanics before you can break them.

I recently spent weeks auditing a fully on-chain perp DEX built in Move on Aptos. This post distills what I learned — both the protocol mechanics and the audit patterns that actually find bugs.

Part 1 — How Perps Work

What Is a Perpetual Future?

A perpetual future is a contract that lets you bet on the price of an asset (like BTC or ETH) without owning it. Unlike traditional futures, perps have no expiration date — you can hold a position forever. The catch is something called the funding rate, which we'll get to.

You can go long (betting the price goes up) or short (betting the price goes down). You put up collateral (usually USDC) and can trade with leverage — meaning you control a position larger than your collateral. At 10x leverage, $1,000 in collateral controls a $10,000 position.

Why leverage matters for auditors: Leverage is a multiplier on both profits and losses. At 40x leverage, a 2.5% price move against you wipes out your entire collateral. This means any bug that shifts the price by even a small percentage can cause real financial damage — liquidations, bad debt, or fund theft.

The Core Components

Every on-chain perp DEX has these components, regardless of which chain or language it's built in:

The 7 pillars of a perp DEX: 1. Orderbook / Matching Engine — Where buy and sell orders meet
2. Oracle System — External price feeds (Chainlink, Pyth) that anchor the protocol to reality
3. Mark Price — The "fair" price used for liquidations and PnL, derived from oracle + orderbook
4. Funding Rate — Continuous payment between longs and shorts to keep perp price ≈ spot price
5. Margin & Collateral — The accounting system that tracks who owes what
6. Liquidation Engine — Force-closes positions that can't cover their losses
7. Risk Engine — ADL, position limits, circuit breakers — the safety nets

Let's break down each one and learn where bugs hide.


1. The Orderbook

On-chain perp DEXes use either an AMM model (GMX-style, liquidity pool) or a CLOB model (Central Limit Order Book — like a traditional exchange). CLOBs are harder to build on-chain but more capital efficient. This is the model I audited.

A CLOB has two sides: bids (buy orders) and asks (sell orders). Orders are matched by price-time priority — the best price wins, and if two orders have the same price, the one submitted first wins (FIFO).

Typical order types: GTC — Good Till Cancelled. Rests on the book until filled or cancelled
IOC — Immediate or Cancel. Fill what you can right now, cancel the rest
Post Only — Only accepted as maker (rejected if it would cross the spread)
Trigger Orders — Stop-loss, take-profit. Sit dormant until a price condition is met, then activate
TWAP — Time-Weighted Average Price. Large order split into smaller slices over time

Audit target — FIFO violations: If the orderbook sorts orders incorrectly, an attacker can jump the queue and get better execution than they deserve. In one audit I reviewed, the bid/ask tie-breaker logic was swapped — buy orders with later timestamps executed before older ones at the same price. This is a direct FIFO violation that lets an attacker front-run other traders.

Audit target — Struct field ordering in Move: Move compares structs lexicographically by field declaration order. If a struct used as a map key has trigger_price as its third field instead of first, the map won't be sorted by price — it'll be sorted by whatever the first field is. I wrote a separate post about this exact bug class.

2. The Oracle System

Oracles provide external price data (the "real" price of BTC, ETH, etc.) to the on-chain protocol. Most perp DEXes use Chainlink, Pyth, or both. The oracle price is the anchor — everything else (mark price, funding rate, liquidation thresholds) derives from it.

Push vs Pull oracles: Traditional Chainlink is push-based — the oracle pushes updates on-chain periodically. Newer Chainlink Data Streams and Pyth are pull-based — someone must explicitly fetch and submit the price. This matters: with pull-based oracles, the timing of price updates becomes an attack surface. Who triggers the update? Can they selectively delay it?

The biggest oracle audit surfaces are:

  1. Staleness. If the oracle goes down, is there a maximum age check on the last price? What happens to positions that should be liquidated but can't because the price feed is stale? During my audit, I found oracle staleness checks using unsafe subtraction (current_time - oracle_timestamp) that would abort if the oracle timestamp was ever ahead of the chain clock due to desynchronization — turning a clock skew into a full protocol halt.
  2. Fallback behavior. When the primary oracle fails, what does the protocol fall back to? Some protocols fall back to the orderbook mid-price — which is directly manipulable by placing orders. If the market isn't restricted to reduce-only during the fallback, an attacker who controls the orderbook mid-price effectively controls the oracle price. This is one of the most dangerous patterns in perp DEXes.
  3. Deviation handling. When primary and secondary oracles diverge significantly, how does the protocol respond? Is there a circuit breaker? Does it switch to a single oracle? In one audit, I traced a path where both oracles were individually healthy but deviated from each other, triggering an "Invalid" status — but the market mode check only looked for "Down" status, not "Invalid." The market stayed fully open while using a fallback book-derived price.
  4. Price precision mismatches. Different oracles use different decimal precisions (Pyth uses negative exponents, Chainlink uses 8 decimals, internal oracles may use something else). Every conversion boundary is a rounding or overflow opportunity.

3. Mark Price

The mark price is the protocol's "fair value" of the asset. It's used for liquidation decisions, PnL calculations, and margin checks. It's not the same as the last trade price or the oracle price — it's typically a composite.

Common mark price formula: mark_price = median(book_mid_price, basis_ema, oracle_price)

book_mid_price — midpoint of the orderbook (best bid + best ask) / 2
basis_ema — exponential moving average of book vs oracle deviation, smoothed over ~150 seconds
oracle_price — external price feed (Chainlink / Pyth)

The median of three values means an attacker needs to control at least 2 of the 3 inputs to manipulate the mark price. This is a critical defense — but it breaks down when the oracle falls back to a book-derived value (as described above), because then all three inputs become book-derived.

Audit target — Mark price commit pattern: Some protocols don't apply mark price updates atomically. They push a new pending mark price, process liquidations, then commit it. During the pending window, longs might be evaluated at the lowest pending price and shorts at the highest (conservative). Check that this ordering is actually protective and can't be exploited by submitting multiple price updates in the same block.

4. Funding Rate

The funding rate is the mechanism that keeps the perp price close to the spot price. If the perp is trading above spot (longs dominating), longs pay shorts. If below spot (shorts dominating), shorts pay longs. This creates an economic incentive to arbitrage the gap.

How funding works: 1. Protocol calculates a premium — how far the perp price is from the oracle price
2. Premium gets combined with a base interest rate and clamped to a range (e.g., ±4%/hr)
3. Result accumulates into a global Cumulative Funding Index (CFI)
4. When you open a position, your entry CFI is recorded
5. Your funding owed = (current CFI - entry CFI) × position size

This is "lazy evaluation" — funding isn't settled continuously for each user. It accumulates globally, and each position settles the difference when it's next touched (increase, decrease, liquidation).

Audit target — CFI corruption: The Cumulative Funding Index is a single global number that accumulates forever. If a bad oracle update injects a wrong value, it's permanent — there's no undo. Check what happens during oracle downtime: does the protocol use a flat interest rate (safe) or does it accumulate zero funding (creates an exploitable gap)? In the protocol I audited, a 6+ minute oracle gap falls back to a flat interest rate — the funding still accrues, it's just "boring" funding. That's the right design.

Audit target — Funding on position increase: When you add to an existing position, how does the entry CFI update? If it naively overwrites with the current CFI, you lose the funding accrued on the original position. If it averages, check the math. This is a subtle but common bug class.

5. Margin & Collateral

Margin is the collateral you put up to hold a position. In a cross-margin system, all your positions share one collateral pool. In isolated margin, each position has its own collateral. Cross-margin is more capital efficient but means one bad position can drain collateral from your profitable ones.

Key margin concepts: Initial Margin — Minimum collateral to open a position (e.g., 2.5% at 40x leverage)
Maintenance Margin — Minimum to keep a position open (usually lower than initial)
Account Balance — Your deposited collateral ± realized PnL ± funding payments
Account Equity — Account balance + unrealized PnL (what you'd have if you closed now)

The margin system is where the most dangerous accounting bugs live, because it's where multiple subsystems interact: PnL, funding, fees, and collateral all flow through the same balance calculations.

Audit target — Fee sign errors: In one audit finding, the settlement function added position fees to the account balance instead of subtracting them. Positive fees = amounts the trader owes. Adding them instead of subtracting inflates the collateral, making liquidatable positions appear healthy. This is a single sign error — + instead of - — but it lets trades execute that should be blocked, directly creating bad debt.

Audit target — Cross/isolated branch divergence: If the protocol supports both cross and isolated margin, the same economic action should have equivalent safety guarantees in both paths. Compare validate_increase_isolated_position vs validate_increase_crossed_position side by side. Any check present in one but missing in the other is a potential exploit.

6. Liquidation Engine

When your account equity drops below the maintenance margin requirement, you get liquidated. The protocol force-closes your position to prevent bad debt (losses that exceed your collateral).

Most perp DEXes have a tiered liquidation system:

Typical liquidation tiers: Stage 1 — Margin Call: Account equity below maintenance margin but above bankruptcy. Protocol tries to close position at market price. Liquidation fee goes to the protocol/insurance fund.

Stage 2 — Backstop Liquidation: Account equity below a lower threshold. A protocol-owned vault (like a DLP vault) absorbs the position as counterparty.

Stage 3 — Auto-Deleveraging (ADL): When the insurance fund is exhausted, profitable positions on the opposite side are force-reduced to offset the bad debt. Nobody likes ADL — it takes profits from winners to cover losers.
  1. Boundary conditions are critical. In one audit, when account balance exactly equaled the backstop liquidation threshold, neither margin-call nor backstop liquidation could proceed. Margin-call required balance > threshold, backstop required balance < threshold. Exact equality = stuck. The position was unliquidatable at that exact value.
  2. ADL priority must be fair. ADL selects which profitable positions get force-reduced. If the selection uses strict greater-than (>) instead of greater-than-or-equal (>=), tied scores keep the first candidate (lower leverage) instead of the intended higher-leverage one. Small comparison operator error, wrong positions get ADL'd.
  3. ADL can be permanently disabled by accident. In one finding, the ADL trigger threshold defaulted to zero (disabled) at market creation, and the setter function was public(package) with no admin entrypoint exposed. ADL was permanently off — meaning the protocol had no backstop for extreme losses beyond the insurance fund.
  4. Liquidation price rounding. If the ADL price rounds down to zero after passing through a round_to_ticker function (common for assets where the price is smaller than the tick size), it violates downstream pricing invariants and aborts the matching engine.

7. Risk Engine — The Safety Nets

The risk engine encompasses everything that prevents the protocol from blowing up: open interest caps, position size limits, market mode controls (halted, reduce-only), and circuit breakers.

Audit target — Market mode bypasses: If the market is halted, can you still submit orders through an alternative path? One finding showed that bulk order placement skipped the market mode check that regular orders enforced. Users could submit bulk orders even when the market was halted — because validate_bulk_order_placement didn't call can_place_order.

Audit target — Open interest lot-size invariant. When the OI cap is hit, the settlement size gets reduced. But if the reduction isn't rounded to the market's lot size, you end up with fractional lots — which violates the invariant that every trade is a multiple of lot_size. This was a finding where the arithmetic adjusted the size but forgot the granularity constraint.


Part 2 — The Audit Playbook

Now that you understand the mechanics, here's how to actually find bugs. These patterns come from real audit experience.

Pattern 1: Asymmetry Detection

Compare every matching pair of functions side-by-side: deposit/withdraw, open/close, long/short, place/cancel, increase/decrease, maker/taker, cross/isolated. Any check present in one path but missing in the other is a finding. This is the single most productive audit pattern for perp DEXes.

Real example: The regular order validation called can_place_order() to check market mode. The bulk order validation didn't. Same economic action, different code paths, different safety guarantees. That's the asymmetry.

Pattern 2: Validate-Then-Mutate Order

Whenever state is mutated before validation, and the error path uses return instead of abort (in Move) or doesn't revert (in Solidity), you have a delete-then-validate bug. The mutation persists even on the "error" path. I wrote a detailed post about this Move-specific pattern.

The rule: Validate first, mutate second. If you must mutate first, use abort on failure, never return.

Pattern 3: The Balance Equation

For every settlement or position update, trace the full balance equation: starting balance + PnL - fees ± funding ± margin delta = ending balance. Every term must have the right sign, the right magnitude, and the right order of operations. A single sign flip on the fee term inflates collateral and blocks liquidations.

Pattern 4: Oracle Fallback Chain

Trace the full oracle degradation path: healthy → stale → deviated → down. At each stage, what price is used? What market mode is activated? Does the mode restriction actually cover all order types (regular, bulk, trigger, TWAP)? If there's a gap — like the market being restrict on "Down" but not on "Invalid" — that's your exploit window.

Pattern 5: Reduce-Only Enforcement

Reduce-only orders should only decrease your position. They should never flip you from long to short (or vice versa). Check the enforcement at both order-level and market-level. What happens with partial fills? In one protocol audit I reviewed (Sui DeepBook), reduce-only orders could flip positions through price improvement — the validation used the limit price but execution happened at a better market price.

Pattern 6: The Rounding Direction

Every division in the protocol has a rounding direction. The question is: does rounding favor the protocol or the user? In a well-designed system, rounding should favor the protocol (round down on payouts, round up on obligations). If it favors the user, dust amounts accumulate into exploitable value over many transactions.

Pattern 7: TWAP/Trigger Order Edge Cases

TWAP orders split a large order into slices over time. What happens when the last slice is smaller than the lot size? What if the remaining size divided by remaining slices rounds to zero? The high-severity finding in the Decibel audit was exactly this: a TWAP slice rounded to zero, got submitted as a zero-size IOC order, hit a size > 0 assertion downstream, and aborted the entire matching engine loop — not just that one order, but every pending order in the queue behind it.

Pattern 8: State Tracking Consistency

When the same fact is tracked in multiple places (common in perp DEXes for performance reasons), any update must be propagated to all locations. Examples I encountered:

  1. Builder fee cap stored in two places. The admin updates trading_fees_manager.builder_max_fee, but builder_code_registry.global_max_fee is set only at initialization and never synced. All validation uses the stale registry value. Admin fee updates are silently ignored.
  2. Contribution lockup stored in two structs. Admin updates Vault.contribution_config.contribution_lockup_duration_s, but the actual minting function reads from VaultShareConfig.contribution_lockup_duration_s — a different field. The update has zero effect.
  3. TP/SL tracked in both pending_order_tracker and position_tp_sl_tracker. Replacing a TP/SL order updates one tracker but not the other. The stale entry produces duplicate trigger requests for the same order.

Pattern 9: Access Control Surface in Move

In Move on Aptos, public fun means any module can call it. public(package) fun restricts to same-package modules. If 99 functions in a module are public(package) and one is public fun, that's likely a copy-paste oversight — and it might be the one function that lets an external module cancel a user's stop-loss orders.

Quick grep: grep -n "public fun" module.move in a file where everything else is public(package) fun. The outlier is your target.

Pattern 10: Initialization and Configuration

Check every default value at initialization. Is ADL threshold defaulting to zero (disabled) with no way to change it? Are referral volume thresholds hardcoded without precision scaling? Are initialization functions public when they should be package-private (allowing out-of-order initialization that bricks other modules)?


Part 3 — Move-Specific Lessons

If you're auditing perps in Move (Aptos or Sui), these patterns are in addition to the generic ones above:

  1. return is not revert. In Move, abort rolls back everything. return commits everything. If state is mutated before a return on an error path, that mutation is permanent. This is the #1 Move-specific bug class I've seen in perp audits.
  2. Struct field order = sort order. When structs are used as keys in BigOrderedMap, comparison is lexicographic by field declaration order. If the field you want to sort by isn't first, your map is sorted wrong — silently, with no error.
  3. No native signed integers. Move doesn't have i64. Protocols implement their own. Check the custom implementation for overflow bugs, especially in funding rate and PnL calculations where negative values are common.
  4. Left shift doesn't abort on overflow. Unlike +, -, *, / which abort on overflow in Move, the << operator silently wraps. If used in price or amount calculations, this produces incorrect values without reverting.
  5. public(package) vs public fun is your access control. There's no onlyOwner modifier in Move. Access control is enforced by function visibility + signer checks. Audit every public fun that takes a &signer parameter — that's your external attack surface.
  6. Oracle timestamp math. current_time - oracle_timestamp aborts if the oracle timestamp is ahead of on-chain time. Use absolute difference, not directional subtraction. This turns a minor clock desync into a protocol-wide halt.

Quick Reference — Where Bugs Hide

// Perp DEX Bug Hotspots — Ranked by Impact
CRITICAL
├── Oracle fallback using manipulable book-derived price
├── Fee sign error inflating collateral (blocks liquidation)
├── Mark price manipulation via 2-of-3 input control
├── CFI corruption from bad oracle update (permanent)
└── Matching engine abort from zero-size TWAP slice

HIGH
├── Market mode bypass on alternative order paths
├── Liquidation boundary off-by-one (unliquidatable positions)
├── ADL permanently disabled by default config
├── Reduce-only order flipping positions
└── State mutation before validation with return-not-abort

MEDIUM
├── Dual-tracked state not synced (fees, lockups, TP/SL)
├── OI cap adjustment breaking lot-size invariant
├── Partial fill handling (reduce-only fully removed)
├── TWAP reinsertion dropping remaining slices
└── Struct field ordering breaking map sort assumptions

Final Thoughts

Auditing perps is hard because every component interacts with every other component. A bug in the oracle affects mark price, which affects liquidation, which affects the insurance fund, which affects ADL, which affects every trader on the platform. The blast radius of a single finding can be protocol-wide.

My approach: start by understanding the full lifecycle of a trade — from order submission through matching, settlement, margin check, funding accrual, and eventual close or liquidation. Then audit each stage looking for the patterns above. Finally, trace the connections between stages, because that's where the most dangerous bugs live — in the gaps between components that were each audited in isolation.

If you're just starting in perp audits, pick one component (oracle, or liquidation, or funding) and understand it deeply before trying to audit the whole system. The complexity is real, but it's learnable.

The most dangerous bugs in perps aren't in the complex math — they're in the simple assumptions that turned out to be wrong. 🔍

Context: Learnings from auditing a fully on-chain perp DEX in Move on Aptos
Findings referenced: real bugs across oracle, liquidation, matching, margin, and risk engine
Applies to: Any perp DEX (Solidity, Move, Rust) — patterns are universal
Follow: @thepantherplus

📝 Move on Aptos and Sui evolves fast — if any tip here is outdated, incorrect, or no longer applies, please reach out on X so I can update this article accordingly.