Calculated from recorded token losses using historical USD prices at the incident time.
0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379b0x9008d19f58aabd9ed0d60971565aa8510560ab41Ethereum0xcd07a7695e3372acd2b2077557de93e667b92bd8EthereumOn Ethereum mainnet block 16574049, transaction 0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379b drained 114824.890807160711319588 DAI from CoW Settlement (0x9008d19f58aabd9ed0d60971565aa8510560ab41). The attacker EOA 0xc0e82c1ed4786f8b7f806d1b8a6335ec485266ff used SwapGuard (0xcd07a7695e3372acd2b2077557de93e667b92bd8) to execute a single DAI.transferFrom(...) that moved the full victim balance directly to the attacker.
The root cause is a broken accounting invariant inside SwapGuard.envelope. The function lets the caller choose both the arbitrary low-level call bundle and the vault address whose balances are monitored. Because the loss check only compares tokens[i].balanceOf(vault) before and after execution, a caller can drain a completely different address that has approved SwapGuard, while presenting an unrelated vault whose balance never changes.
SwapGuard is designed as a public executor that performs arbitrary interactions and then enforces a post-condition on token balances. The relevant public entrypoint is envelope(Data[] interactions, address vault, IERC20[] tokens, uint256[] tokenPrices, int256[] balanceChanges, uint256 allowedLoss). Its safety model assumes that the supplied vault is the asset source that should be protected.
That assumption is unsafe when the executor also performs unrestricted low-level calls. In ERC20, transferFrom(from, to, amount) spends the allowance that from previously granted to msg.sender. If some third party has already approved SwapGuard, then any successful call executed by SwapGuard can move that third party's tokens without ever touching the caller-supplied vault.
The relevant pre-state at block 16574048 was:
114824890807160711319588 raw DAI units (114824.890807160711319588 DAI).uint256.max DAI allowance.0xeb8f71a5669a55cf90e384c77e74c4bdf9ae7754 had 0 DAI before execution and 0 DAI after execution.Those three conditions were sufficient for a permissionless exploit. No privileged key, privileged contract role, or attacker-specific artifact was required.
This incident is an ATTACK-category ACT exploit, not a speculative loss hypothesis. SwapGuard exposes a public function that accepts attacker-controlled interaction calldata and an attacker-controlled accounting scope. The contract then checks losses against the chosen vault instead of the actual address whose tokens are debited by the executed calls. The exploit transaction used that mismatch to call DAI.transferFrom(CoW Settlement, attacker, full_balance) through SwapGuard while monitoring a different address with a flat DAI balance. As a result, the victim lost its full approved DAI balance even though SwapGuard computed zero loss for the only tracked token. The broken invariant is: any token loss caused by envelope must be accounted against the true token source whose allowance is consumed, not against a caller-chosen proxy address. Because envelope is public and the required allowance was already on-chain, any unprivileged actor could realize the same exploit predicate from public state alone.
The verified SwapGuard source shows the exact invariant break:
function envelope(
Data[] calldata interactions,
address vault,
IERC20[] calldata tokens,
uint256[] calldata tokenPrices,
int256[] calldata balanceChanges,
uint256 allowedLoss
) public payable {
uint256[] memory balancesBeforeInteractions = new uint256[](tokens.length);
for (uint256 i = 0; i < tokens.length; i++) {
balancesBeforeInteractions[i] = tokens[i].balanceOf(vault);
}
for (uint256 i = 0; i < interactions.length; i++) {
Data memory interaction = interactions[i];
(bool success, bytes memory returnData) =
interaction.target.call{value: interaction.value}(interaction.callData);
if (!success) revert BadInteractionResponse(returnData);
}
for (uint256 i = 0; i < tokens.length; i++) {
uint256 balanceAfterInteraction = tokens[i].balanceOf(vault);
int256 actualBalanceChange =
balanceAfterInteraction.toInt256() - balancesBeforeInteractions[i].toInt256();
if (actualBalanceChange < balanceChanges[i]) {
totalLoss += (balanceChanges[i] - actualBalanceChange).toUint256() * tokenPrices[i];
}
if (totalLoss > allowedLoss) revert LostMoreThanAllowed(totalLoss, allowedLoss);
}
}
Two facts matter in that implementation. First, the caller fully controls vault. Second, the interaction loop performs arbitrary low-level calls without binding the calldata to the monitored vault. That means the debit source inside transferFrom and the address used for accounting can diverge.
The decoded execution trace for the exploit transaction confirms that this is exactly what happened:
SwapGuard::envelope(
[Data({ target: DAI, callData: transferFrom(CoW Settlement, attacker, 114824890807160711319588) })],
0xEB8f71A5669A55Cf90e384C77e74c4bdf9aE7754,
[DAI],
[0],
[0],
type(uint256).max
)
Dai::balanceOf(0xEB8f71A5669A55Cf90e384C77e74c4bdf9aE7754) -> 0
Dai::transferFrom(0x9008D19f58AAbD9eD0D60971565AA8510560ab41, attacker, 114824890807160711319588)
Dai::balanceOf(0xEB8f71A5669A55Cf90e384C77e74c4bdf9aE7754) -> 0
The trace also shows the victim pre-state immediately before the exploit:
Dai::balanceOf(0x9008D19f58AAbD9eD0D60971565AA8510560ab41) -> 114824890807160711319588
Dai::allowance(0x9008D19f58AAbD9eD0D60971565AA8510560ab41, SwapGuard) -> 115792089237316195423570985008687907853269984665640564039457584007913129639935
Because the monitored vault started at 0 DAI and ended at 0 DAI, SwapGuard computed actualBalanceChange = 0. The supplied expected change was also 0, so actualBalanceChange < expectedBalanceChange never became true and totalLoss remained 0. Meanwhile, the real token source was CoW Settlement, whose DAI balance was fully drained.
The transaction receipt independently confirms the value movement with a successful DAI Transfer log from CoW Settlement to the attacker for the exact same amount. This establishes a deterministic, code-backed exploit chain:
transferFrom(victim, attacker, victimBalanceBefore) as an interaction executed by SwapGuard.The exploit does not depend on the observed helper contract address. The vulnerability exists in SwapGuard's public envelope semantics and can be reproduced by any unprivileged caller with fresh addresses.
The adversary flow is a single-transaction direct allowance drain.
16574049, the attacker observes that CoW Settlement holds 114824.890807160711319588 DAI and that SwapGuard already has unlimited DAI allowance from CoW Settlement.0x6b175474e89094c44da98b954eedeac495271d0f with calldata equivalent to transferFrom(CoW Settlement, attacker, victimBalanceBefore).0xeb8f71a5669a55cf90e384c77e74c4bdf9ae7754 as the monitored vault because its DAI balance is zero and stays zero, making the vault-scoped accounting check inert.0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379b executes the exploit. The sender is 0xc0e82c1ed4786f8b7f806d1b8a6335ec485266ff, the transaction succeeds, and the receipt emits the DAI Transfer to the same EOA.0.001510649805383131 ETH in gas. No additional settlement step is needed because profit is realized immediately in the exploit transaction itself.The measurable loss was the complete DAI balance that CoW Settlement had left spendable through SwapGuard at the fork block used by the exploit.
114824890807160711319588114824.890807160711319588 DAI0xc0e82c1ed4786f8b7f806d1b8a6335ec485266ffThe exploit completed in one successful transaction and did not require follow-up transactions, liquidation windows, or privileged post-processing.
0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379b165740490x9008d19f58aabd9ed0d60971565aa8510560ab410xcd07a7695e3372acd2b2077557de93e667b92bd80x6b175474e89094c44da98b954eedeac495271d0fenvelope implementation captured from verified sourcebalanceOf(vault), transferFrom(victim, attacker, amount), and unchanged vault balanceTransfer log from CoW Settlement to the attacker EOA