On Ethereum mainnet, transaction 0xef9994ac862318ccf3ebdb66c181bb159651373b945aea59a966608d7b98684f succeeded at block 24978818 on 2026-04-28T13:19:59Z. The attacker deployed two helper contracts and exploited the public batch(address[],bytes[]) function on legacy contract 0x044dc3e39c566a95011e272ec800dbd2cc9c057c to route a call through an EIP-7702 delegated EOA (0xc6ddf907). Because 0xc6ddf907 was a registered admin of QNT pool 0xdd4f556e1b42d9b29294a7eeb6d6a5059bbbe16a, the call passed the pool’s _isAdmin(msg.sender) check on forwardTransaction(), causing QNT.transfer(attacker, 1974.5465 QNT). The victim pool lost 1,974.546547972979064297 QNT, estimated at approximately $124.9K.
0x044dc3e39c566a95011e272ec800dbd2cc9c057c.// AI source reconstruction by app.dedaub.com
// 2026.04.29 01:23 UTC
pragma solidity 0.8.31;
error BatchRevert(uint256 index, address recipient, bytes returnData);
contract Contract {
function batch(address[] calldata recipients, bytes[] calldata data) public payable {
require(recipients.length == data.length);
for (uint256 i = 0; i < recipients.length; i++) {
(bool success, bytes memory returnData) = recipients[i].call(data[i]); // <-- NO ACCESS CONTROL
if (!success) {
revert BatchRevert(i, recipients[i], returnData);
}
}
}
receive() external payable {}
}
The batch() function is public with no access control — no onlyOwner, no whitelist, no require(msg.sender == ...). Any caller can specify any target address and any calldata, and the contract will execute recipients[i].call(data[i]) without restriction.
0xdd4f556e1b42d9b29294a7eeb6d6a5059bbbe16a.forwardTransaction function.The critical function is forwardTransaction (selector 0x4d5a8e10):
function _0x4d5a8e10(address target, uint256 gasAmount, bytes calldata data) external {
require(_isAdmin(msg.sender)); // <-- access control: checks admin flag
require(stor_0_0_0 != 1); // pool must not be in 'failed' state
emit EventA0e077e8(target, gasAmount, data);
uint256 g = gasAmount > 0 ? gasAmount : gasleft();
(bool ok,) = target.call{gas: g}(data); // <-- arbitrary external call
require(ok);
}
function _isAdmin(address a) internal view returns (bool) {
return (_getParticipantInfo[a].flags & 0xff) != 0; // bottom byte of flags != 0
}
The forwardTransaction() function does have access control — require(_isAdmin(msg.sender)). The admin check looks up the caller in the _getParticipantInfo mapping and verifies the bottom byte of the flags field is non-zero. During the exploit, msg.sender was 0xc6ddf907, which was a registered admin of the pool. The check passed, and the function executed QNT.transfer(attacker, 1974.5 QNT) via the arbitrary external call.
This means the exploit relied on being able to make calls appear to originate from the admin EOA 0xc6ddf907.
0xc6ddf90790b433743bd050c1d1d45f673a3413f4.0xef010095538e1c40e82dbc9dfb2f7f88580d0d0824688e.0xef01 = EIP-7702 delegation designator.0x00 = version byte.0x95538e1c40e82dbc9dfb2f7f88580d0d0824688e = implementation address.0x95538e1c40e82dbc9dfb2f7f88580d0d0824688e (unverified, ~4 KB).0x34fcd5be = executeBatch((address,uint256,bytes)[]), 0x2f54bf6e = isOwner(address).executeBatch access control behavior cannot be confirmed from source. The presence of isOwner(address) suggests owner-based access control, meaning executeBatch likely requires the caller to be an authorized owner.Before EIP-7702, 0xc6ddf907 was a plain EOA. Only the holder of its private key could sign transactions from it. The pool’s require(_isAdmin(msg.sender)) was secure because it relied on the assumption that only the EOA owner could call functions “from” that address.
After EIP-7702 delegation, 0xc6ddf907 behaves as a smart contract: anyone can call its functions, and those calls execute with 0xc6ddf907 as msg.sender to downstream contracts.
Expected behavior: the QNT pool’s forwardTransaction() is admin-only. Only the private key holder of admin addresses can invoke it. The admin EOA 0xc6ddf907 was trusted to only call forwardTransaction for legitimate operations.
Actual behavior: EIP-7702 delegation broke the trust assumption that “only the private key holder of address X can send transactions from X.” After delegation, the EOA’s code can be invoked by any caller. When the batch contract called executeBatch() on the EIP-7702 implementation, the implementation executed the requested operation from 0xc6ddf907’s context — so msg.sender to the QNT pool was 0xc6ddf907, a registered admin. The pool’s _isAdmin(0xc6ddf907) check passed because the address is indeed an admin — but the call was initiated by the attacker, not by the admin.
The role of the batch contract: The attacker went through the batch contract rather than calling 0xc6ddf907.executeBatch() directly. This is significant — the EIP-7702 implementation has isOwner(address), suggesting executeBatch checks whether msg.sender is an authorized owner. If so, the batch contract (0x044dc3e3) was likely registered as an owner of the EIP-7702 wallet, making it a necessary stepping stone. The attacker could not call executeBatch directly from their deployed contract because it was not an authorized caller. The batch contract, with its missing access control, became the entry point that the attacker exploited to reach the authorized executeBatch.
The attack chain combined three weaknesses:
| Component | Weakness | Role in Attack |
|---|---|---|
Batch contract 0x044dc3e3 | No access control on batch() | Entry point: anyone can route calls through it to reach authorized executors |
EIP-7702 account 0xc6ddf907 | EIP-7702 delegation + admin of pool | Delegation turns trusted admin EOA into callable contract; its admin status on the pool is weaponized |
QNT pool 0xdd4f556e | forwardTransaction trusts callers by address | Admin address-based trust is broken when the admin EOA becomes a delegated contract |
Source caveat: the EIP-7702 implementation at 0x95538e1c remains unverified. The isOwner(address) selector and the attacker’s choice to route through the batch contract strongly suggest owner-based access control on executeBatch, but this cannot be confirmed without the implementation source. If executeBatch has no access control, the batch contract would not be strictly necessary, but the core finding — EIP-7702 delegation breaking address-based trust — remains unchanged.
0xf5604f65 submitted a contract-creation transaction deploying AttackContract1 (0xee039cb7).AttackContract1 deployed AttackContract2 (0x9af7eb6c) via CREATE.AttackContract1 called AttackContract2 via selector 0x0ce23abb to trigger the exploit.AttackContract2 called batch() on the unpermissioned batch contract, targeting the EIP-7702 account 0xc6ddf907 with executeBatch() calldata.0xc6ddf907, which (via EIP-7702 delegation) executed executeBatch() with one operation: call QNT pool 0xdd4f556e with forwardTransaction(QNT, 0, transfer(attacker, 1974.5 QNT)).forwardTransaction() checked _isAdmin(0xc6ddf907) — passed, since 0xc6ddf907 is a registered admin — and executed QNT.transfer(attacker, 1974546547972979064297).0xf5604f65 CREATEs AttackContract1 0xee039cb7..0: AttackContract1 CREATEs AttackContract2 0x9af7eb6c (init bytecode 8,597 bytes, runtime 16,890 bytes)..1: AttackContract1 -> AttackContract2, CALL, selector 0x0ce23abb (4-byte trigger function, no arguments)..1.0: AttackContract2 -> batch contract 0x044dc3e3, CALL, selector 0xf38f59d7 = batch(address[],bytes[]). Input: 740 bytes.recipients = [0xc6ddf90790b433743bd050c1d1d45f673a3413f4]data = [executeBatch(...)].1.0.0: batch contract -> EIP-7702 account 0xc6ddf907, CALL, selector 0x34fcd5be = executeBatch((address,uint256,bytes)[]). Input: 484 bytes.msg.sender to EIP-7702 impl = 0x044dc3e3 (batch contract). If executeBatch checks isOwner(msg.sender), the batch contract must be a registered owner.operations = [(0xdd4f556e, 0, forwardTransaction(...))].1.0.0.0: 0xc6ddf907 -> QNT pool 0xdd4f556e, CALL, selector 0x4d5a8e10 = forwardTransaction(address,uint256,bytes). Input: 228 bytes.msg.sender = 0xc6ddf907 — registered admin, _isAdmin check passes.target = 0x4a220e6096b25eadb88358cb44068a3248254675 (QNT token)gasAmount = 0 (uses gasleft())data = 0xa9059cbb000...6b0a5687c913387de9 = transfer(0xf5604f65..., 1974546547972979064297).1.0.0.0.0: QNT pool -> QNT token, CALL, transfer(address,uint256). Output: 32 bytes (success).The outermost transaction is a contract creation (9,989 bytes init code). The nested exploit calldata:
Layer 1 — batch(address[],bytes[]) at batch contract (740 bytes):
0xf38f59d7
recipients.length = 1
recipients[0] = 0xc6ddf90790b433743bd050c1d1d45f673a3413f4
data[0] = <executeBatch calldata>
Layer 2 — executeBatch((address,uint256,bytes)[]) at EIP-7702 account (484 bytes):
0x34fcd5be
operations.length = 1
operations[0]:
target = 0xdd4f556e1b42d9b29294a7eeb6d6a5059bbbe16a (QNT pool)
value = 0
data = <forwardTransaction calldata>
Layer 3 — forwardTransaction(address,uint256,bytes) at QNT pool (228 bytes):
0x4d5a8e10
target = 0x4a220e6096b25eadb88358cb44068a3248254675 (QNT token)
gasAmount = 0 (uses gasleft())
data = transfer(0xf5604f6545d5827a01801ffa5c48f5c61258fa01, 1974546547972979064297)
The transfer amount 0x6b0a5687c913387de9 = 1974546547972979064297 = 1974.546547972979064297 QNT (18 decimals).
| Metric | Value |
|---|---|
| Token drained | 1,974.546547972979064297 QNT |
| Estimated USD | ~$124.9K |
| Gas used | 2,270,882 |
| Gas price | 1.7735 gwei |
| Gas cost | 0.004027 ETH (~$6.40) |
| Attack complexity | Single transaction, no flash loan, no AMM interaction |
No flash loan, AMM swap, oracle manipulation, or price feed interaction appears in this transaction. The loss comes entirely from the EIP-7702 delegation enabling impersonation of a trusted admin address.
0x1.AttackContract1 0xee039cb73872827a5118cdd67e0b24e45651e49f.0x1f1: event from QNT pool 0xdd4f556e (topic 0xa0e077e8...) — matches EventA0e077e8(address, uint256, bytes) emitted by forwardTransaction(). Decoded data: target = QNT token, gasAmount = 0, data = transfer(attacker, 1974546547972979064297). This confirms forwardTransaction() executed and the _isAdmin(msg.sender) check passed.0x1f2: QNT Transfer(address,address,uint256) (topic 0xddf252ad...) from 0xdd4f556e to 0xf5604f65, amount 1974546547972979064297.0xc6ddf907 is 0xef0100 + 95538e1c40e82dbc9dfb2f7f88580d0d0824688e, confirming EIP-7702 delegation.0xc6ddf907 calling forwardTransaction() on the QNT pool, and the pool accepted the call (did not revert). The Dedaub decompilation confirms forwardTransaction requires _isAdmin(msg.sender), proving 0xc6ddf907 is a registered admin.batch(address[],bytes[]) = 0xf38f59d7, executeBatch((address,uint256,bytes)[]) = 0x34fcd5be, forwardTransaction(address,uint256,bytes) = 0x4d5a8e10, transfer(address,uint256) = 0xa9059cbb.0x95538e1c) remains unverified; executeBatch access control behavior is inferred from the isOwner(address) selector and the attacker’s routing choice.