How a Safe module drained 2,900 rsETH in one block
Just after 4:38 UTC a searcher slipped into the first slot of an Ethereum block. Twenty-four seconds earlier someone had created a token that did not exist the day before. By the time the block closed, 2,900 aEthrsETH had left a Gnosis Safe that nobody had signed.
01 · One block, no stolen key
Two enabled modules and a public batch path
The wallet that lost the tokens is a Gnosis Safe, version 1.3.0, created in the autumn of 2023. A Safe is built for groups: several owners hold keys, and a transaction only goes through when enough of them sign. That is the feature people buy it for. It also has a second, quieter feature. Owners can turn on a module, which is a contract they trust to act for them. Once a module is on, it can move funds without collecting a fresh round of signatures. Vaults, keepers and liquidity bots use that so they do not have to wake the owners every time a position needs a nudge.
Anyone who can reach that module can move funds the same way. The Safe is designed for that. The risk sits in who is allowed to talk to the module.
This wallet had been sitting for months with Kelp rsETH deposited on Aave V3. On Aave those tokens show up as aEthrsETH, a receipt for the deposit, and they had WETH borrowed against them. Two modules had been calling it: a gateway from that first autumn, and a Uniswap v4 liquidity module from early this year. Keepers already used a public batch contract to reach those modules. The attacker reused a door that had already been in use.
At 04:38 UTC on 15 September a bot called a function named yoink() and
took the first slot in the block. The person who had built the trap
does not appear in that block at all. Nobody stole a key. Nobody phished
an owner. The Safe processed every module call the way it was told to.
Bitquery indexes every call, event and transfer on Ethereum. We pulled the capture transaction, every aEthrsETH outflow from the Safe that day, and the wallets the rsETH landed in. Decoding the module calls is what the smart contract API is for. Following the receipt token out of the Safe is what the address APIs sit on.
02 · The batch contract that named itself
A target check that trusted address(this)
In front of the modules sits a public executor, a contract anyone can call. It exposes a batch function: you pick a target and you hand it a list of jobs. A check on the target is supposed to keep strangers out. Think of it as a receptionist who will only put a call through if the name is on a list.
In the capture transaction the helper passed the executor itself as the
target. The receptionist looked at the name, saw its own building, and
put the call through. The executor then called itself. In the code this
is a check that treats address(this) as trusted. Once you are inside
that trusted context, the inner jobs run as if the executor had asked
for them, which means they can walk through the enabled module and into
the Safe.
The next job was another batch, this time aimed at the gateway module. That module is allowed to call the Safe's module-execution function. It did so with the operation set to delegate call, which is a technical way of saying the code runs as if the Safe itself had written it. From there the work was handed to the Uniswap v4 liquidity module, which was also already turned on.
None of this was a surprise to the keepers who already used the same recipe. One of them had called the batch function dozens of times. The path was live long before this morning. The Safe did not malfunction. The hole was the caller list on the public batch contract.
The Safe processed every module call as designed. The hole was the caller list on the public batch contract.
03 · A junk token and a vanilla pool
PAT, then Uniswap v4, then an unlocker
You cannot simply ask a Safe to send tokens to a stranger. Even with a module in play, the money has to leave through some market the chain will accept as a trade. That is why the attacker spent two blocks building a market that did not exist the day before.
At 04:38:23 they put a token on chain called PAT and minted a huge pile of it to a helper contract. Twelve seconds later the helper seeded a Uniswap v4 pool of aEthrsETH against PAT. The pool's hook field is the zero address, which means this is an ordinary v4 pool. We mapped the hooked version of Uniswap v4 on Robinhood Chain. This pool is the plain kind.
PAT has no other market and no reason to exist except as the other side of this swap. The helper can "pay" PAT, which costs it nothing real, and walk away with the aToken. That is the whole point of a junk pair.
Once the capture transaction started, the Safe approved the aToken and one wei of PAT through Permit2, Uniswap's shared allowance contract, and minted a liquidity-provider NFT. The aToken moved from the Safe into Uniswap's Pool Manager. A separate contract then unlocked the pool, swapped, and took the aToken to the helper. The helper paid PAT as the other side. The Safe still holds the NFT. The NFT is empty.
Some public notes called that unlocker a hook. The call trace shows an ordinary call into Pool Manager, the same unlock any v4 router can make. The distinction matters because a hook would imply custom pool logic. There was none.
04 · How the capture tx ran
From yoink() to the frozen pile
Ethereum lets searchers compete to land a transaction first in a block.
The prize, on a morning like this, is whoever can finish the drain
before the person who built the trap. A searcher whose bot exposes a
function named yoink() won that race. Its own wallet called that
function, paid 999 wei, and became the outer caller. The original
attacker has no transaction in the block.
The liquidity module is a proxy. Its beacon points at a logic contract.
On chain the named functions are safe and initialize. We do not name
a vendor, because the logic is unverified and a name would be a guess.
After the self-batch, the rest of the work is ordinary: approve the
aToken, talk to Permit2, approve one wei of PAT, mint the position, then
clear the approvals so the trail looks tidy.
After the mint, the helper talks to the unlocker. Transfers, not swap
events, are what we trust here. They show the helper receiving aEthrsETH
and paying PAT. Pool Manager's take() function sends the aToken over.
The helper then calls Aave's withdraw with the amount set to maximum,
and with both the user and the receiver set to itself. The aEthrsETH
burns. Bare rsETH lands on the helper and moves to the Yoink bot. The
bot sells a thin slice on a real ETH/rsETH v4 pool,
enough to skim some ether, and parks the rest.
One hundred and seventy-five calls ran inside that single transaction. Every one of them succeeded. Depth twenty-four. Thirty-nine logs. The Safe never signed.
| Call range | What it did |
|---|---|
| 0–24 | yoink() into the helper, Aave probe, zero-amount aToken transfer |
| 25–31 | batch(self) → gateway module → Safe exec, operation 1, delegate call execute |
| 32–110 | LP module: Permit2, mint UNI-V4-POSM, 2,900 aEthrsETH into Pool Manager |
| 111–133 | Unlocker swap + take aEthrsETH to helper; helper pays 2,905.81 PAT |
| 134–156 | Aave withdraw, burn aEthrsETH, rsETH to helper, then to Yoink bot |
| 157–174 | Sell 17.63 rsETH for 18.95 ETH; park 2,882.37 at 0xC70f00…; skim ETH |
05 · Where the rsETH sits now
A freeze wallet, a skim, and a later seize
Most stolen crypto moves. This pile did not. The Yoink bot sent 2,882.37 rsETH to a wallet that has been live since late 2022, a reused address rather than a fresh one. No rsETH has left that wallet since the capture. The native ether already sitting there is older money from before this morning. The small ether skim from the in-block sale went to a second Yoink wallet. Watching whether the large pile ever moves is what MoneyFlow is for.
Kelp's public notes speak of a wallet pause. We did not see an admin pause call on chain. What we can say is simpler: the outflows are zero.
The first drain took the deposit receipt and left the WETH loan sitting on the Safe. On Aave that is a health problem. Health is the ratio of collateral to debt. Pull the collateral and leave the debt, and the position becomes easier to liquidate. About an hour later Yoink came back for that leftover.
The second hit used Aave's liquidation path instead of walking the
modules again. Yoink took a
WETH flash loan from Morpho Blue, then called Aave's liquidationCall
with rsETH as collateral, WETH as debt, and the Safe as the user. Aave's
own event records 168.83 WETH covered and 157.71 rsETH seized. The net
debt-token transfer on the Safe looks smaller, because Aave mints
accrued interest first and only then burns the repay. Yoink sold the
seized rsETH on Fluid, repaid Morpho, and kept a thin ether skim.
Copycats spent the same hour repeating the PAT recipe in smaller sizes. They ran until a few minutes before the owners turned the two modules off.
| When UTC | What moved |
|---|---|
| 04:38:47 | 2,882.37 rsETH to 0xC70f00… ; 17.63 sold for 18.95 ETH, skim to 0x6c42… |
| 05:24:47 | Copycat 50 aEthrsETH, same PAT recipe. 40 rsETH into CoW in four tens, 10 to 0x006d0e0d… |
| 05:43–05:55 | Repeat v4 PAT deposits (one nine, then 0.5 drips). Same Safe, same module path. |
| 05:47:59 | Same attacker hits a second Safe: 86,632 DUSD and 38,248 USDC out; 23.69 ETH to the attacker EOA. |
| 05:53:59 | Yoink Aave seize: 168.83 WETH debt covered, 157.71 rsETH taken, sold on Fluid. |
| 06:00 | Owner tx disables the two modules used in this drain (gateway and LP). |
| 06:37 | Same sender repays 248 WETH of Aave debt. |
| 07:20 | Owner tx disables nine other modules on the same Safe. |
06 · What is still on the Safe
The loop did not empty
It is easy to read a morning like this as a wallet being cleaned out. The snapshot after the extracts, the seize and a later WETH repay says otherwise. The Safe still holds 50,279 aEthrsETH and 51,344 of Aave's variable-debt WETH token. Native rsETH left on the address is 7.18 tokens. Among the eighteen Uniswap v4 position NFTs is the empty PAT receipt. Other token balances on the address look like spam, the kind of dust that follows any busy Ethereum wallet.
| Still on the Safe after the morning | Amount |
|---|---|
| aEthrsETH | 50,279 |
| variableDebt WETH | 51,344 |
| Native rsETH | 7.18 |
| UNI-V4-POSM NFTs | 18, including the empty PAT position |
The two modules used in the drain were turned off at 06:00, about an hour and a half after the capture. Copycats had been running until a few minutes before that. At 06:37 the same sender repaid 248 WETH of Aave debt. At 07:20 an owner transaction turned off nine other modules on the same Safe. The leftover Aave position is still open.
A second Safe was hit at 05:47 by the same attacker, with a fresh PAT mint. Yoink did not front-run that one. DUSD and USDC went into Pool Manager, the helper swapped them out, and ether landed on the attacker. Public notes put the dollar size near the DUSD plus the USDC. We treat it as the same crew and a smaller take.
07 · Did labels help?
Our database named venues, not the people in the case
Address labels are how an investigation finds out whether anyone else has already named the wallets in a story. We queried them and wrote nothing back. The goal was to see what the record already said.
Our directory.labels table has no rows for the victim Safe, the
executor, the attacker, PAT, the modules, the searcher, or the freeze
wallet. It does name the well-known venues through the
address labels API: Uniswap v4 Pool
Manager, CoW settlement, Morpho Blue, Fluid. That confirms routes we
already had from the call trace. It does not name anyone in the case.
We call the searcher Yoink because that is the function its bot ran. We do not claim to know who runs it.
Early public notes had Morpho and Fluid swapped. The flash loan is Morpho Blue. The rsETH-to-ether sale on the seize is Fluid.
08 · Related, and not the same
April Kelp, and a second Safe
In April a bridge mint created a large pile of unbacked rsETH. That story is about Kelp's minting path, different people, and a different date. This September event is a user module path on one Safe. Kelp's rsETH backing is not the failure mode here, and we do not claim Kelp's contracts failed.
The confusion is understandable. Both stories involve rsETH, and both moved a lot of it. The mechanics are not the same. One is a mint. This one is a deputy that was already on.
09 · How we read the chain
Transfers first, then events, then labels
A swap event and a token transfer can disagree about who paid whom, especially inside a dense transaction. Where they disagreed, we kept the transfer. A transfer is tokens changing hands. An event is a contract's description of what it thinks it did. The same queries sit behind Bitquery MCP.
| What could go wrong | How we handled it |
|---|---|
| Swap event signs vs transfers | Transfers are ground truth: Safe → Pool Manager → helper for aEthrsETH, helper pays PAT. take() matches. |
| Debt token 8.71 vs 168.83 WETH | Aave Burn splits interest mint and repay. LiquidationCall event matches WETH covered and rsETH seized. |
| Hook vs unlocker | PoolKey.hooks is the zero address at initialize and at mint. 0x5d20 is a CALL into unlock. |
| Merge balances on the Safe | aEthrsETH and variableDebtWETH are the two we trust; other tokens on the address are treated as spam. |
| Kelp pause as a fact | We report zero rsETH outflows. We do not claim we saw the pause call. |
10 · The record
The addresses behind the story
| Role | Address |
|---|---|
| Victim Safe 1.3.0 | 0x40E93a…7AbA8 |
| Capture tx | 0x0e7680…8705 |
| Public executor | 0x4f00…8ebC |
| Gateway module | 0xea18…c4ca |
| Uniswap v4 LP module | 0xdcdc…82ab |
| Original attacker EOA | 0x0dC2…9b66 |
| Helper | 0x1060…5Cb1 |
| PAT | 0x8762…61f5 |
| Unlocker | 0x5d20…e8bb |
| Yoink EOA | 0xFDe0…455A |
| Yoink bot | 0x80BF…D4e4 |
| rsETH still here | 0xC70f00…80ea0 |
| ETH skim | 0x6c42…bf0e |
| Second Safe | 0x6a1f…7ea2 |
| Owner-tx sender | 0x8c2a…fee8 |
The same hops are on Coinpath.
If you hold rsETH in a Safe that uses a public executor in front of a liquidity module, the two modules in this case are worth treating as burned until you have turned them off yourself. An empty capture-day balance on someone else's wallet is no proof your own path is closed.
Ask these questions in plain English
The same index is on the Bitquery MCP server. An assistant can list which modules have been calling a Safe, decode a capture transaction, follow an aToken to rsETH, or watch a wallet for outflows, without writing the query by hand.
This article is provided for informational and educational purposes only and reflects analysis of publicly available on-chain data as of the dates indicated. It does not constitute legal, financial, compliance, or investment advice, and nothing in it is a recommendation to buy, sell, or hold any token or asset.
This piece reconstructs one Safe on Ethereum on 15 September 2026 from public call, event and transfer records. Yoink is the name we use for the searcher whose bot called yoink(); we describe that bot by its on-chain behaviour and do not claim who runs it. Module, executor and attacker addresses are named by behaviour. We do not claim a vendor for the unverified LP logic, and we do not claim Kelp’s contracts failed. Dollar figures use the capture-day print on the frozen rsETH pile. Owner identity is unlabeled. Aave seize math uses the LiquidationCall event, not the net debt-token transfer. Blockchain addresses are pseudonymous, and a transaction between two addresses does not by itself establish the identity, intent or knowledge of any party. Every entity attribution here is an inference that may be incomplete or wrong.
Nothing herein should be relied upon as a definitive determination of fact. Readers should conduct their own independent verification before taking any action. The authors and publisher accept no liability for any loss or damage arising from reliance on this material. All trademarks and company names are the property of their respective owners.
Run this kind of trace on your own data
Every figure here came from public Ethereum call, event and transfer records: Safe module execs, Uniswap v4 unlocks, Aave burns and the wallets the rsETH landed in.