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.
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.
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.
Every on-chain perp DEX has these components, regardless of which chain or language it's built in:
Let's break down each one and learn where bugs hide.
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).
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.
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:
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.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.
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.
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.
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.
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.
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.
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:
balance > threshold, backstop required balance < threshold. Exact equality = stuck. The position was unliquidatable at that exact value.>) 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.public(package) with no admin entrypoint exposed. ADL was permanently off — meaning the protocol had no backstop for extreme losses beyond the insurance fund.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.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.
Now that you understand the mechanics, here's how to actually find bugs. These patterns come from real audit experience.
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.
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.
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.
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.
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.
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.
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.
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:
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.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.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.
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)?
If you're auditing perps in Move (Aptos or Sui), these patterns are in addition to the generic ones above:
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.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.i64. Protocols implement their own. Check the custom implementation for overflow bugs, especially in funding rate and PnL calculations where negative values are common.+, -, *, / which abort on overflow in Move, the << operator silently wraps. If used in price or amount calculations, this produces incorrect values without reverting.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.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.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. 🔍
📝 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.