Unifund's $10.2K Loss and How Olympix Would Have Prevented It
Unifund's legacy trading-group core kept one number for every group's WETH, and it paid out whatever that number said. Early on November 11, 2026, an attacker found a way to make the number grow faster than the WETH behind it. By routing a swap in a circle that started and finished in WETH, the attacker got the same balance change written to the same ledger line twice, then asked the contract to settle up. The contract did, using WETH that belonged to other groups.
According to ExVul's alert, the core lost 4.9941 WETH across five exploit transactions, and the original attacker wallet netted 4.7555 ETH after gas across its three. At ETH's price of roughly $2,052 at the time, that's about $10,249 taken from the groups whose funds sat in the contract. As of November 12, 2026, Unifund hadn't published a statement or a post-mortem.
A Shared Pot With One Ledger Per Group
Unifund was built around social trading groups. Participants pool capital into a group, the group trades Uniswap tokens, and everyone shares the result. The platform's token is iFUND, and the attack transactions moved iFUND alongside WETH.
The trading-group core is the settlement layer for that model. It holds real WETH for many groups at once in a single contract and tracks each group's share in an internal ledger. When a group calls finish() to wrap up its positions and withdraw() to pull its funds, the core reads the ledger and sends that amount. The payout follows the ledger.
That design works as long as the ledger is exact. The moment one group's entry runs ahead of its real holdings, the core still has enough WETH to honor it, because the other groups' money is in the same contract. So a ledger error doesn't fail loudly. It quietly moves value from one group to another.
Trades don't happen in the core itself. The core hands swap execution to a separate swap facet, and that facet is where the ledger got its extra credit.
Two Updates, One Key
ExVul traced the root cause to the facet's swapExactTokensForTokens function. When a group swaps, the facet measures the balance change on the input token and the balance change on the output token, then posts each one to that token's ledger entry. For an ordinary trade, those are two different tokens with two different entries, and the bookkeeping lines up with reality.
The function also accepted a cyclic path, where the first and last tokens are the same. ExVul's example is WETH to token A to token B and back to WETH. With that path, the input token and the output token are both WETH, so the input-side update and the output-side update both land on the group's WETH entry. The facet treats them as two independent events. As ExVul puts it, each real swap gain gets credited twice.
A simple way to picture it is a shop till that counts a refund as both money out and money back in, against the same drawer, because the customer and the payee have the same name. The cash in the drawer barely moves, but the tally climbs every time the loop runs.
ExVul's numbers from the flagged transaction show how far apart the two figures got. The attacker's group physically held 5.628136547 WETH, while its ledger said 10.256273095 WETH. When finish() and withdraw() ran, the core paid out the ledger figure, and the 4.628136547 WETH difference came out of other groups' funds.
The missing piece was a single comparison. Nothing in the facet stopped a path whose input and output were the same asset, and nothing netted the two updates into one when they pointed at the same key.
Inside the Flagged Transaction
The transaction ExVul flagged landed at 02:02:23 UTC on November 11 in block 26388567. It came from the attacker's EOA and deployed the attack contract in the same call, so the contract existed for exactly one run.
On-chain token transfers show the shape of that run. The attack contract borrowed 12 WETH from Balancer's vault and repaid the same 12 WETH before the transaction ended, so no permanent capital was needed. It spent 0.1 WETH on iFUND, created fresh Uniswap V2 liquidity positions, and sent 10,000 iFUND into the trading-group core to set up a group position.
The cyclic swap ran through the facet next. Two transfers of 4.243615 WETH and 0.386521 WETH flowed from a Uniswap pair into the core as swap output, and those are the real gains the ledger counted twice. Then the settlement fired. The core sent out 10.256273 WETH, the full inflated ledger figure, against a group that really held about 5.63 WETH. The attacker unwound the liquidity positions, collected the WETH and returned the Balancer loan.
The attacker's EOA deployed a fresh contract for each run between 01:58 and 02:05 UTC. ExVul counts five exploit transactions in total, three of them from the original wallet. On-chain transfers show the core's net WETH outflow in the attacker's next two runs was far smaller, about 0.27 WETH and 0.08 WETH.
How Olympix Would Have Caught the Double Credit
Olympix reads a contract as states, transitions and entry points. In Unifund's case, the state that matters is each group's ledger entry per token, and every function that writes to those entries is an entry point worth mapping. The invariant is easy to infer from how the core pays out: for every group, the ledger entry for WETH should never exceed the WETH that group really owns.
From there the engine narrows the search. It maps paths into the swap facet, keeps the feasible ones, and focuses on paths that touch that invariant. Had the team run Olympix on this code, static analysis would have surfaced the signal at the heart of this bug: a function that takes a caller-supplied token path and writes two balance updates whose keys come from the first and last tokens of that path, with no check that those tokens differ. When they're equal, two writes collapse onto one key.
BugPoCer would have turned that signal into proof. It starts from the invariant and searches for a sequence of calls that breaks it. A group with any WETH position, a call to swapExactTokensForTokens with a path that starts and ends in WETH, and a call to finish() would have been enough. BugPoCer would have returned that sequence as a counterexample, with the ledger value and the real WETH balance diverging after the swap, the minimal conditions needed to reach it, and a generated Forge test that replays it. That test would have been the November 11 drain, produced in development, where the fix is small: reject a path whose first and last tokens match, or net the two updates before writing.
Because Olympix runs at every merge and before release, a change that introduced cyclic-path support, or a refactor that split the two updates apart, would have been checked against the same invariant each time.
What Builders Should Take From It
Delta-based accounting is everywhere in DeFi, and it's easy to get right for the common case and wrong for the edge case. If a function computes balance changes from a caller-supplied path, the key for every write needs to be collision-proof for every path a caller can send, including round trips. Unifund's bug is a close relative of the swap-accounting gap that cost Malt Protocol $13.4K in October, where swaps with 1 DAI of real input were credited with over 6,000 DAI.
Pooled settlement makes the stakes higher. When many users' funds sit in one contract, a ledger that drifts from reality keeps paying out, and the people who lose are the ones who did nothing wrong. Checking that internal balances stay tied to real holdings, at every entry point, is the guard that keeps a bookkeeping slip from turning into a withdrawal.
Legacy contracts deserve the same care. ExVul describes Unifund's core as legacy, and it still held other groups' WETH, which was all an attacker needed. If your team wants to see how invariant-driven analysis would read your own contracts, book a technical walkthrough.
On-Chain References
Attacker EOA: 0x6ee1...f881
Attack Contract: 0x27b3...59d2
Victim Contract (Trading-Group Core): 0x152a...6227
Vulnerable Swap Facet: 0x4891...cec5
iFUND Token: 0x04b5...b0f0
Exploit Transaction: 0x5f1e...27a0
More post-mortems
See every post-mortem →See what Olympix finds on your own code
Send us a repo that's already been audited, and we'll run Olympix at the same commit and show you what comes back.




