# How a Missing `assert!` Drained $3.44M From Typus Finance — and Why the Code Looked Correct

On October 15, 2025, at 13:05 UTC, an attacker began draining Typus Finance's TLP liquidity pool on the Sui network. Thirty-four minutes later, the contracts were paused — but the pool was already empty. Over the hours that followed, the stolen SUI, xBTC, and suiETH were swapped to USDC on-chain, then bridged to Ethereum via Circle's CCTP in 14 transactions. Total loss: $3.44 million.

The root cause was a single line of Move code that looked like an authorization check — called the right function, passed the right arguments, sat in the right place — but silently discarded its result. The oracle module had been live for eleven months.

I pulled the vulnerable source from GitHub, compared it against the post-incident rewrite, and traced the attack on-chain. This is what I found.

* * *

## The vulnerable function

The oracle module (`typus_oracle::oracle`) managed price feeds for Typus Finance's TLP pool. Prices were updated through a function called `update_v2`:

```move
public fun update_v2(
    oracle: &mut Oracle,
    update_authority: &UpdateAuthority,
    price: u64,
    twap_price: u64,
    clock: &Clock,
    ctx: &mut TxContext
) {
    // check authority
    vector::contains(&update_authority.authority, &tx_context::sender(ctx));
    version_check(oracle);

    update_(oracle, price, twap_price, clock, ctx);
}
```

Source: [`typus_oracle/sources/oracle.move`](https://github.com/Typus-Lab/typus/blob/a918e98c4f7d3a28d0d809d3263d8c21e90d3c01/typus_oracle/sources/oracle.move), commit `a918e98` (September 24, 2025 — three weeks before the exploit).

Read that function slowly. `vector::contains()` returns a `bool`. That `bool` is computed, evaluated — and then thrown away. The return value is never bound to a variable, never passed to `assert!`, never checked. Move does not warn on an unused pure-function return value. The compiler is happy. The tests pass. The authorization is theater.

What the code should have said:

```move
assert!(
    vector::contains(&update_authority.authority, &tx_context::sender(ctx)),
    E_UNAUTHORIZED
);
```

One line. The entire $3.44M exploit hinged on the difference between calling a function and acting on its result.

## Why this survived eleven months

The oracle module was deployed on November 13, 2024. MoveBit audited Typus Finance's contracts in May 2025 — but the oracle module was **excluded from the audit scope**. The module that controls every price in the system, that determines what every swap is worth, was the one module nobody formally reviewed.

The `UpdateAuthority` struct was a **shared object** on Sui — meaning any transaction could reference it. The authorization check was supposed to ensure only whitelisted addresses could call `update_v2`. Without the `assert!`, the whitelist existed but was never enforced. Every address on Sui had oracle-write access for eleven months.

The reason nobody noticed: every legitimate oracle update came from a whitelisted address. The check "worked" in the sense that whitelisted addresses passed it. It also "worked" for every other address on the network. The two populations — authorized callers and everyone else — were indistinguishable to the code.

## The attack

At 13:05 UTC on October 15, the attacker submitted the first malicious transaction (`6KJvWtmrZDi5MxUPkJfDNZTLf2DFGKhQA2WuVAdSRUgH`). The attack followed a clean three-phase sequence:

**Phase 1 — Price manipulation.** The attacker called `update_v2` directly — no exploit contract, no flash loan, no reentrancy. Just a direct function call with fabricated prices. In one instance, a token's oracle price was set to 651,548,270 while another was set to 1. The `UpdateAuthority` shared object (accessible to anyone) was passed as an argument, the `vector::contains` check returned `false`, nobody read the `false`, and `update_()` executed with the attacker's prices.

**Phase 2 — Arbitrage extraction.** The TLP pool relied entirely on oracle prices for swap calculations. With a 651-million-to-1 price ratio, the attacker could swap tiny amounts of one token for enormous amounts of another. In one swap, 1 SUI was exchanged for approximately 60,000,000 xBTC base units (0.6 xBTC at 8 decimals — roughly 0.6 BTC in value). Ten separate manipulation-then-swap sequences drained the pool systematically.

**Phase 3 — Exit.** The stolen suiETH, xBTC, and SUI were converted to USDC through Cetus, Haedal Protocol, and Turbos Finance. Approximately 3,430,717 USDC was bridged to Ethereum via Circle's CCTP in 14 separate transactions, then swapped to 3,430,241.91 DAI on Curve. The attacker's initial funding — 0.041 ETH — originated from Tornado Cash on BSC.

Total loss: 588,357.9 SUI + 1,604,034.7 USDC + 0.6 xBTC + 32.227 suiETH. Approximately $3.44 million.

The team detected the attack at 13:24 (19 minutes in), paused all contracts at 13:39, and identified the root cause by 13:42 — three minutes after pausing. The fix was obvious once you saw the missing `assert!`. Finding it was the hard part.

## The fix — and what it reveals about the original design

Five days after the exploit, Typus pushed commit `4e118f0` — a major refactor of the oracle module. The change wasn't just adding an `assert!`. They redesigned the authorization model entirely:

| Before (vulnerable) | After (fixed) |
| --- | --- |
| `UpdateAuthority` — shared object, whitelist of addresses | `UpdateCap` / `UpdateCaps` — shared object, `for: address` binding |
| `vector::contains()` result discarded | `assert!(update_cap.for == ctx.sender(), EInvalidUpdateCap)` |
| `update_v2` public function | `update_with_update_cap` public function |
| Authorization computed but never enforced | Authorization computed and asserted |

A note on what the fix is *not*: `UpdateCap` is still a **shared object** (`transfer::share_object()` in `create_update_cap`). Anyone can reference it in a transaction. The access control is still a runtime check — the `for: address` field binds the cap to a specific sender, and `assert!` enforces it. This is *not* an owned-capability pattern (the codebase already has one — `ManagerCap`, which uses `transfer::transfer` and can only be presented by its owner). Typus likely chose the shared pattern because multiple oracle updater addresses (automated crankers) need to reference the cap concurrently, which an owned object would block.

The post-fix `update_with_update_cap`:

```move
public fun update_with_update_cap(
    oracle: &mut Oracle,
    update_cap: &UpdateCap,
    price: u64,
    twap_price: u64,
    clock: &Clock,
    ctx: &TxContext,
) {
    version_check(oracle);
    assert!(update_cap.`for` == ctx.sender(), EInvalidUpdateCap);
    update_(oracle, price, twap_price, clock, ctx);
}
```

The entire defense is one `assert!`. Remove it and the cap's `for` field is decoration — exactly the same failure mode as the original `vector::contains()` call. The fix works, but it is procedural (a runtime check the next developer must remember to keep), not structural (an ownership constraint the type system enforces). That is worth stating plainly: the system that lost $3.44M to a forgotten check was fixed by adding a check and not forgetting it this time.

## What a linter sees

I ran the vulnerable `oracle.move` through a static analysis pass looking for a specific pattern: a public function that takes a mutable reference (`&mut`) to a shared object but does not require a capability parameter. This is the class of defect that `MOV-001` in [move-test-gen](https://github.com/mehvetero/move-test-gen) targets — public state-mutating functions without a capability gate.

The result is instructive but honest: `update_v2` takes `&UpdateAuthority` (an access-control struct), which a name-only heuristic would read as "has authorization." A regex-based linter sees a struct with "Authority" in the name and assumes it is a gate. The actual defect — that the gate's result is discarded — lives one abstraction layer deeper: you need to trace the return value of `vector::contains()` and confirm it reaches an `assert!` or an `if` branch. That is control-flow analysis, not pattern matching.

This is a known ceiling for line-based static analysis. The linter catches the structural shape (public function + mutable shared object + no capability), but the specific failure mode (unused return value of an authorization check) requires either a compiler warning for unused pure-function results or a data-flow analyzer that tracks boolean returns to assertion sites.

Move does not currently emit an "unused return value" warning for pure functions. A Rust-equivalent `#[must_use]` annotation on `vector::contains` would have caught this at compile time. That annotation does not exist in Move today.

## The uncomfortable pattern

This is the third Sui DeFi exploit I have analyzed in detail — after [Cetus ($223M)](https://mehvetero.com/three-sui-exploits-one-disease-why-the-math-looked-fine-keeps-costing-hundreds-of-millions) and [Aftermath ($1.14M)](https://mehvetero.com/how-one-missing-assert-drained-114m-from-aftermath-finance). The surface details differ — arithmetic overflow, signed-value boundary, authorization bypass — but the structural shape is the same:

1.  **The code looked correct to every reader.** The function name said "check authority." The comment said "check authority." The called function was the right one. The arguments were correct. The only thing missing was one keyword.
    
2.  **The module that mattered most was the one that wasn't audited.** Aftermath's perpetuals module was a late addition. Typus's oracle module was excluded from scope. The highest-value target is systematically the one with the least review.
    
3.  **The fix is trivial in hindsight.** `assert!(...)` instead of a bare call. `(x as u128)` instead of `x * y`. These are one-line fixes that would have prevented nine-figure losses. The difficulty was never in writing the fix — it was in knowing the fix was needed.
    

This is not a tooling problem or an audit problem. It is a coverage problem. The code that controls the most value gets the least scrutiny, because it ships last, ships fastest, or ships outside the scope of an engagement that was scoped months earlier.

* * *

**Addresses referenced in this analysis:**

*   Vulnerable package: `0xaf44818c67a878b9eba0c63186b00e80d9fc3d1e2ae02f00fa3993b0e683bff3`
    
*   First attack transaction: `6KJvWtmrZDi5MxUPkJfDNZTLf2DFGKhQA2WuVAdSRUgH`
    
*   Attacker (Ethereum): `0xeb8a15d28dd54231e7e950f5720bc3d7af77b443`
    
*   Funds destination: `0x4502cf2bc9c8743e63cbb9bbde0011989eed03c1`
    
*   Vulnerable source: [commit `a918e98`](https://github.com/Typus-Lab/typus/blob/a918e98c4f7d3a28d0d809d3263d8c21e90d3c01/typus_oracle/sources/oracle.move) (September 24, 2025)
    
*   Post-fix source: [commit `4e118f0`](https://github.com/Typus-Lab/typus/blob/4e118f0f4c5d8b09837b3d0dcfb6501dc79f687f/typus_oracle/sources/oracle.move) (October 20, 2025)
    

**Disclosure:** I build [move-test-gen](https://github.com/mehvetero/move-test-gen), an open-source coverage checker and security linter for Sui Move. The linter's `MOV-001` rule targets missing access control in public functions — the structural class this exploit belongs to. The specific failure mode (unused authorization return value) is beyond the current linter's reach, as noted above. Credit to [Defimon](https://defimon.xyz/blog/typus-finance-hack-october-2025) and [Typus Finance's post-mortem](https://medium.com/@TypusFinance/typus-finance-tlp-oracle-exploit-post-mortem-report-response-plan-ce2d0800808b) for the on-chain timeline and financial figures.
