
Date: September 11, 2026
Chain: Ethereum
Loss: 15.45 ETH, about $38K, taken from 11 wallets
Class: missing caller check on a settlement function that pulls tokens from a caller-chosen address
According to security researcher kankodu, one holder of ether.fi's liquidETH vault token had used Veda's AtomicQueue more than a year earlier and left an approval behind, letting the queue spend their tokens. About three months before September 2026, the same holder switched on EIP-7702 smart account features in Coinbase Wallet, which gave their address code. On September 11, 2026, at 07:20:11 UTC, those two unrelated decisions met a third piece of old code, and 12.37 liquidETH left that wallet in a transaction its owner never signed.
Ten other wallets lost tokens in the same transaction, 14.45 liquidETH and 7.05 USDC in all, which the attacker sold for 15.45 ETH, about $38K at the time. It was possible because AtomicQueue's solve() function let any caller name any address as the solver, and then paid out of that address's approval as if the solver had agreed to it.
liquidETH is the share token of ether.fi's Liquid ETH vault, and AtomicQueue is a Veda contract that handled withdrawals of it. The queue, deployed on Ethereum at 0xD45884B592E316eB816199615A95C182F75dea07 and verified on Sourcify in April 2024, works like a limit order book for those shares. A holder calls updateAtomicRequest() to say how much of one token they'll offer, which token they want back, the price they'll accept and a deadline. The verified source describes a request as "effectively the same as placing a limit order."
Third parties called solvers fill those orders. A solver calls solve() with a list of users, and the queue runs in three steps. It pulls each user's offered tokens and sends them to the solver, then calls finishSolve() on the solver so it can do whatever it needs to source the payment, and finally pulls the wanted tokens from the solver and pays each user. For that last step to work, a solver approves the queue to spend its want token ahead of time. A user who offered liquidETH also had to approve the queue to spend liquidETH, and that approval doesn't expire unless the user revokes it.
ether.fi CEO Mike Silagadze described the contract in a reply to SlowMist's alert as an old Veda contract that a small number of users had approved. Old as it was, it still held standing approvals, and anyone who had approved it was a potential solver as far as the code was concerned.
From the verified AtomicQueue source on Blockscout, these are the lines in solve() that decide who pays, with the loop bodies in between trimmed:
The solver arrives as a plain parameter, and nothing in the function compares it to msg.sender, checks a signature, or looks it up in a list of approved solvers. The first transfer sends the users' offered tokens to that address, the callback asks it to finish, and the last transfer takes the want token from it. Whoever calls solve() decides whose approval pays for the trade.
The callback was the only thing standing between that design and a drain. According to security researcher kankodu, calling finishSolve() on an ordinary wallet with no code simply fails, which reverts the whole solve. The queue treated a successful callback as the solver agreeing to the trade, and for years that assumption held, because most addresses holding a stale approval were plain wallets.
EIP-7702 changed that. It lets an ordinary address delegate to smart account code, and the transaction trace shows the queue's finishSolve() call on each victim running delegated code from a Coinbase Smart Wallet implementation. That code accepted a call it didn't recognize and returned without error, so the queue read silence as consent. Kankodu described the result as three holes lining up: a callback pattern that assumes a successful call means intent, a stale approval, and a wallet that does nothing when called with an unknown function.
The lesson for builders sits in that last transfer. When a function moves tokens out of an address, the code has to prove that address asked for it, and a callback that returns without reverting proves nothing, because the code at the other end might not even know the function exists.
The attacker's wallet, 0xa5CC6e490Bce9185fA47b421f2EaC677A83B64Ea, received 0.08513 ETH from Tornado Cash at 07:06:59 UTC. Thirteen minutes later it sent a single contract creation transaction, and the whole attack ran inside the constructors of the contracts it deployed. ExVul described the outer contract as self-destructing.
The deployed contract created a worthless token of its own and, acting as a queue user, placed a request offering that token and wanting liquidETH. According to ExVul, the attacker set the price so that each request paid out exactly the victim's approved balance. Then it called solve() with itself as the only user and a victim as the solver. The queue moved the fake token to the victim, called finishSolve() on the victim's smart account, got no error back, and pulled the victim's liquidETH through the victim's own approval to pay the attacker's request.
It repeated that for nine liquidETH holders. The largest single loss was 12.37 liquidETH, followed by 1.87 liquidETH, and the remaining seven wallets lost amounts between 0.11 and 0.0006 liquidETH. Two more wallets had approved the queue for USDC and lost 5.69 USDC and 1.36 USDC the same way. In total, 14.45 liquidETH and 7.05 USDC moved to the attack contract.
Still inside the same transaction, the contract sold the liquidETH through three Uniswap V4 swaps for 2.47, 6.49 and 6.48 ETH, and swapped the USDC on Uniswap V3 for 0.00285 WETH, which it unwrapped. The attacker's wallet received 15.45 ETH when the transaction ended.
The money didn't stay long. At 07:23:11 UTC, three minutes after the exploit, the attacker deposited 10 ETH into the Tornado Cash router, followed by five deposits of 1 ETH and five of 0.1 ETH, with the last one landing at 07:27:59 UTC. By then 15.5 ETH had gone into the mixer, and the wallet kept only a small remainder.
SlowMist said it reached out to the ether.fi team privately before publishing its alert, which named the attacker, the vulnerable AtomicQueue and the transaction. Silagadze replied that the issue was resolved and that impacted users would be reimbursed. Nucleus, which ran its own AtomicQueue deployment for deprecated vaults, posted the same day that it had pushed a fix protecting funds held as its vault tokens and asked users to revoke approvals to its queue. Researcher pashov pointed holders to Revoke.cash, since every wallet still approving an old queue was exposed until it revoked.
Olympix reads a contract the way an attacker would, as states, transitions and entry points, and takes in the context around the code to infer the rules the system depends on but never writes down. In AtomicQueue, the state that matters is whose tokens can move, and solve() is the entry point that moves them. The engine maps every path through it and uses signals to decide which paths are worth attacking. Olympix's static analysis surfaces the signal here: an external function that calls transferFrom on an address taken straight from its arguments, with nothing tying that address to msg.sender, a signature or an allowlist, and an external callback as the only check in between.
The invariant for this queue fits in one line: tokens should only leave a solver's address when that solver chose to fill the trade. BugPoCer takes that rule and tries to break it, running Counterexample-Guided Invariant Refutation from the queue outward, executing every feasible path into solve() until one moves tokens from an address that never called it. The path that breaks it needs only a fresh caller, a request for a token the caller controls, and a solver address that has approved the queue and whose code returns from finishSolve() without reverting.
The counterexample BugPoCer hands back is that path made concrete: the attacker's updateAtomicRequest() call, a solve() naming a stranger as solver, the callback that returns empty, and the want token arriving in the attacker's account out of the stranger's approval, along with the minimal conditions that trigger it and a generated Forge test that replays it. That's the September 11 transaction, produced in development and at the merge that introduced solve(), long before 2026.
Olympix has flagged this class before in other codebases, where a function trusted a caller-supplied address as if it had authorized the action. Because Olympix runs automatically in development, at every merge and before release, a team gets a standing view of findings like this across its whole codebase, including contracts that are older but still hold approvals.
AtomicQueue's solve() worked for years because the wallets it trusted happened to fail loudly, and the moment some of them stopped failing, the missing check became a way into their approvals. Any function that moves tokens out of an address it was handed needs proof that the address wanted it, and that proof can be checked long before deployment. To see how Olympix traces every path where your contracts spend someone else's approval and returns a runnable counterexample for each one that breaks, book a technical walkthrough.
Protocol: ether.fi Liquid (Veda AtomicQueue)
Chain: Ethereum
Date: September 11, 2026, 07:20:11 UTC
Loss: 14.45 liquidETH and 7.05 USDC, sold for 15.45 ETH
Attacker EOA: 0xa5CC...64Ea
Attack contract: 0x7f5A...DEC1
Attack helper contract: 0x679c...663E
Attacker's fake offer token: 0xD9B5...3f64
Vulnerable AtomicQueue: 0xD458...ea07
liquidETH token: 0xf0bb...416C
Largest victim (12.37 liquidETH): 0x69da...47D4
Funding tx (Tornado Cash): 0xaac2...c7dd
Exploit tx: 0x7cbe...599b
Block: 25952624
First Tornado Cash deposit (10 ETH): 0xa9f2...ea9a
SlowMist: TI Alert on the ether.fi AtomicQueue exploit
ExVul: Alert and root cause of the liquidETH AtomicQueue drain
kankodu: How three holes lined up in the EtherFi helper contract hack
Mike Silagadze: Reply confirming the old Veda contract and reimbursement