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

Search for a command to run...

No comments yet. Be the first to comment.
Cetus lost $223M to a shift overflow. Aftermath lost $1.14M to a negative fee. Bucket Protocol ships a decimal scaling bug today. All three passed audits. All three have the same root cause. I've spe

I've been building move-test-gen, a security tool for Sui Move smart contracts. It started as a test generator and grew into a coverage checker with mutation testing and a security linter. This post c

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 3

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 oracle module (typus_oracle::oracle) managed price feeds for Typus Finance's TLP pool. Prices were updated through a function called update_v2:
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, 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:
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.
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.
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.
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:
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.
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 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.
This is the third Sui DeFi exploit I have analyzed in detail — after Cetus ($223M) and Aftermath ($1.14M). The surface details differ — arithmetic overflow, signed-value boundary, authorization bypass — but the structural shape is the same:
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.
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.
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 (September 24, 2025)
Post-fix source: commit 4e118f0 (October 20, 2025)
Disclosure: I build 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 and Typus Finance's post-mortem for the on-chain timeline and financial figures.