# How One Missing Assert Drained $1.14M From Aftermath Finance — A Signed Integer Exploit on Sui



*A technical breakdown of the Aftermath Finance perpetuals exploit (April 29, 2026). How a negative fee value bypassed validation, inflated collateral, and allowed an attacker to drain $1.14M USDC in 36 minutes — and what a single line of code would have prevented.*

* * *

## TL;DR

*   **Protocol:** Aftermath Finance Perpetuals (Sui Network)
    
*   **Date:** April 29, 2026
    
*   **Loss:** $1,139,927 USDC across 11 transactions in 36 minutes
    
*   **Root cause:** Signed fixed-point fee value accepted without non-negativity check. `fee <= max` was validated, `fee >= 0` was not.
    
*   **Auditor:** OtterSec (November 2025) — did not catch this issue
    
*   **Fix:** One assert: `assert!(integrator_taker_fee >= 0)`
    

* * *

## Background: What Aftermath Perps Does

Aftermath Finance operates a fully onchain perpetual futures exchange on Sui. Unlike hybrid architectures that rely on off-chain sequencers, every order, match, and settlement executes in Move smart contracts.

The protocol supports an **integrator system** — third parties can integrate with Aftermath's clearing house and charge their own fees on top. Each integrator registers a fee configuration per user account.

This is the system the attacker exploited.

* * *

## The Vulnerable Code Path

The perpetuals contracts are not open-source. The reconstruction below is based on [DarkNavy's on-chain bytecode disassembly](https://www.darknavy.org/web3/exploits/aftermathfi-perpetuals-negative-integrator-fee-collateral-inflation/), which preserved the decompiled Move code in full.

### On-Chain Addresses

| Component | Address |
| --- | --- |
| **Clearing House Package** | `0x21d001e8b07da2e3facb3e2d636bbaef43ba3c978bd84810368840b7d57c5068` |
| **USDC Clearing House Object** | `0x95969906ca735c9d44e8a44b5b7791b4dacaddf70fbdfbda40ccd3f8a9fd4920` |
| **Interface Wrapper** | `0x9e208bed81b7072fa75af8f9eaca42ced3ec8154bb04f5b948c2a9455125d136` |
| **Signed Fixed-Point Library** | `0x46234ba81f3a5ba6571383233df0f9ea5fe60a3a327537be1f5fec447bced693` |
| **Attacker** | `0x1a65086c85114c1a3f8dc74140115c6e18438d48d33a21fd112311561112d41e` |

### Step 1: Fee Validation (the bug)

When a trade executes, the clearing house calculates taker fees:

```move
// clearing_house.move (reconstructed from disassembly)
fun calculate_taker_fees(
    position, account, quote_filled, base_fee, gas_fee, integrator_info
) {
    let integrator_taker_fee = get_integrator_taker_fee(integrator_info);
    let max_taker_fee = get_integrator_max_taker_fee(account);
    
    // THE BUG: only upper-bound check
    assert!(integrator_taker_fee <= max_taker_fee, invalid_integrator_taker_fee);
    
    // missing: assert!(integrator_taker_fee >= 0, invalid_integrator_taker_fee);
    
    // ... compute fees
}
```

The comparison uses a **signed fixed-point** type. The `less_than_eq` function in the fixed-point library does signed comparison:

```move
// signed fixed-point library
public fun less_than_eq(a: u256, b: u256): bool {
    return (a ^ SIGN_BIT) <= (b ^ SIGN_BIT);
}
```

The XOR with `SIGN_BIT` converts to two's complement ordering. Any negative value has `SIGN_BIT` set, so after XOR it becomes a small number — smaller than zero (which has `SIGN_BIT` unset after XOR). Result: **negative <= 0 is always true.**

### Step 2: Collateral Calculation (the damage)

After fees are "validated," the clearing house applies them to the taker's collateral:

```move
// clearing_house.move (reconstructed)
fun process_fill_taker(position, account, ...) {
    let (taker_fee_total, integrator_fee_total, integrator_addr) = 
        calculate_taker_fees(position, account, quote_filled, base_fee, gas_fee, integrator_info);
    
    // THE CRITICAL LINE
    let collateral_delta = filled_value - (taker_fee_total + integrator_fee_total);
    
    position::add_to_collateral_usd(position, collateral_delta, price_scale);
}
```

When `integrator_fee_total` is negative:

```plaintext
collateral_delta = filled_value - (taker_fee + NEGATIVE_FEE)
                 = filled_value - (taker_fee - |fee|)
                 = filled_value - taker_fee + |fee|
```

The negative fee **adds** to the collateral instead of subtracting. The taker account receives phantom USDC credit that does not correspond to any real deposit.

### Step 3: Cash Out

The attacker then calls two functions to convert phantom collateral into real USDC:

```move
// 1. Move collateral from position to account balance
clearing_house::deallocate_collateral(clearing_house, account, amount);

// 2. Withdraw account balance as actual USDC
account::withdraw_collateral(account, amount);
```

The `to_balance` conversion function in the fixed-point library actually checks for negative values:

```move
public fun to_balance(value: u256, scale: u256): u64 {
    if (value >= SIGN_BIT) abort 0;  // rejects negative!
    return (value / scale) as u64;
}
```

But this check is at the **exit boundary**, not at the entry point. By the time the flow reaches `to_balance`, the collateral delta has already been computed as a large positive number (filled\_value + |fee|). The negative value was consumed in the fee subtraction step — it never reaches `to_balance`.

**Defense-in-depth failure:** the inner function rejects negative, but the outer function lets it in. The attacker's path never touches the inner function with a negative value.

* * *

## The Attack Transaction by Transaction

The attacker followed the same pattern in each PTB (Programmable Transaction Block):

```plaintext
1. Create two fresh accounts (maker + taker) via interface::create_and_return_account
2. Create positions for both accounts
3. Deposit 100 USDC into the maker-side account ONLY
4. Create integrator vault for attacker's address
5. Add integrator config to taker-side account: max_taker_fee = 0
6. Place a limit order on the maker side
7. Call clearing_house::create_integrator_info(attacker_address, NEGATIVE_FEE)
8. Start taker session → market order fills against maker
9. End session → deallocate free collateral → withdraw USDC from taker
10. Clean up transient accounts
```

**100 USDC deposited on maker side → 100,000+ USDC withdrawn from taker side.** The delta is pure phantom collateral.

### All 11 Profitable Transactions

| # | Time (UTC) | Transaction | Net USDC Gain |
| --- | --- | --- | --- |
| 1 | 08:55:50 | `531W14qrdyoD8tZrA34CzSU8pe4Dz1bNEAEcq2mC7E7u` | ~103K |
| 2 | 08:55:54 | `FQALUkZD1NnY9cDYoah2Zus6Z4TSp6voj1k2XKuA863Y` | ~103K |
| 3 | 09:06:40 | `u4zE7XBnPKxETVPBfF7XtKYd694qEHa6QTx1LDHwujT` | ~103K |
| 4 | 09:11:40 | `2s3ybJFhwzzLSMTsugGSAN9b1SgjaL1uc8yPaqVbJ1wz` | ~103K |
| 5 | 09:13:30 | `9UJLFirEqNuXEPkVZQL1MfNHZTfHy2zDH99MyoSb7k61` | ~103K |
| 6 | 09:14:47 | `CKfb8nYzTA9XWSs6A3PpaCT6HqQFp48nBLBPwEV4EGPC` | ~103K |
| 7 | 09:15:27 | `7P8TFYuvACtoPG79SpsBhucEiJ7MvmdVj11DomLjgrXu` | ~103K |
| 8 | 09:18:13 | `BqWYBVHnx8USdbKH24BNJD1gB7WxZWGeGz4WqMVmEFMn` | ~103K |
| 9 | 09:18:52 | `CSaHz1ABSeUbaLv7zdqFkH4ibLxEBxPWqYTBmXyCXVXB` | ~103K |
| 10 | 09:19:05 | — | ~103K |
| 11 | 09:31:29 | — | ~103K |

Total: **$1,139,927 USDC** net gain. Seeded with only **$1,100 USDC** across maker accounts.

* * *

## Why the Audit Missed It

OtterSec audited Aftermath Perps in November 2025. The vulnerability was introduced in August 2025 — it was in-scope during the audit.

This is not a knock on OtterSec. They are a top-tier Move auditor. But the bug reveals a systematic blind spot in manual review:

**The code *reads* correct.** `assert!(fee <= max_fee)` looks like a complete validation. A reviewer scanning the fee path sees a bounds check and moves on. The absence of a lower-bound check is invisible unless the reviewer specifically asks: "can this signed value be negative, and what happens if it is?"

Manual review checks what IS there. A linter checks what ISN'T there.

A static rule — *"signed fixed-point value accepted as function parameter without* `assert!(value >= 0)` *in the validation path"* — would have flagged `calculate_taker_fees` on the first scan.

* * *

## The Pattern: Upper-Bound-Only Validation on Signed Types

This is not unique to Aftermath or Move. The pattern appears wherever:

1.  A protocol uses signed types for values that must be non-negative (fees, amounts, rates, prices)
    
2.  Validation checks `x <= max` but not `x >= 0`
    
3.  A negative value inverts arithmetic downstream
    

**Other instances of this pattern class:**

*   **Dango DeFi (April 2026, Sui):** Insurance fund donations accepted without checking `amount > 0`. Negative donations extracted collateral. $410K drained, returned by whitehat.
    
*   **Traditional finance:** Signed integer underflows in banking transaction processors have caused similar issues — a negative transfer amount credited instead of debited.
    

The defense is simple: **every entry point that accepts a value which must be non-negative needs an explicit lower-bound assert, regardless of what inner functions check.**

* * *

## What Would Have Caught This

### At the code level

```move
// One line. That's the entire fix.
assert!(integrator_taker_fee >= 0, invalid_integrator_taker_fee);
```

### At the tooling level

A lint rule scanning for signed-type parameters without non-negativity asserts. The rule would flag any function that:

*   Accepts a signed fixed-point value as input
    
*   Uses that value in arithmetic (subtraction, addition to balances)
    
*   Has no `assert!(value >= 0)` or equivalent guard before the arithmetic
    

This is the class of bug that manual review systematically misses and static analysis systematically catches.

* * *

## Timeline

| Date | Event |
| --- | --- |
| August 29, 2025 | Vulnerability introduced in Aftermath Perps codebase |
| November 2025 | OtterSec audit conducted — issue not flagged |
| April 29, 2026 08:55 UTC | First exploit transaction |
| April 29, 2026 09:31 UTC | Last exploit transaction (11 total, 36 minutes) |
| April 29, 2026 | Protocol paused, clearing house set to `is_emergency = true` |
| Post-exploit | $1.14M USDC drained. Only perps affected; AMM, staking, other products safe |

* * *

## Key Takeaways

1.  **Signed types in financial code are a loaded gun.** If a value must be non-negative, validate at the entry point — not at a downstream conversion function.
    
2.  `x <= max` **is not a complete bounds check on signed values.** The lower bound matters. This is obvious in retrospect and invisible in review.
    
3.  **Move's type safety didn't help here.** Move prevents reentrancy, memory corruption, and unauthorized object access. It does not prevent signed arithmetic bugs or missing input validation. The "Move is safe" narrative needs qualification.
    
4.  **Audits have systematic blind spots.** A top-tier auditor reviewed this code and missed it. The bug is not subtle — it is a one-line omission. But manual review optimizes for what IS there, not what ISN'T. Tooling fills this gap.
    
5.  **The entire exploit was a single PTB.** No flash loans, no oracle manipulation, no multi-transaction setup. Open accounts, set negative fee, trade, withdraw. Clean enough to repeat 11 times in 36 minutes.
    

* * *

*Analysis based on* [*DarkNavy's on-chain disassembly*](https://www.darknavy.org/web3/exploits/aftermathfi-perpetuals-negative-integrator-fee-collateral-inflation/)*. Source contracts are not public — all code shown is reconstructed from bytecode.*

*Post-mortem notes maintained at* [*defi-incident-notes*](https://github.com/mehvetero/defi-incident-notes/blob/main/aftermath.md)*.*
