Calculated from recorded token losses using historical USD prices at the incident time.
0x05498574BD0Fa99eeCB01e1241661E7eE58F8a85Optimism0x7131FF92a3604966d7D96CCc9d596F7e9435195cOptimism0xd5832070A81d607E8932B524f507B47117564CD3Optimism0x889be273BE5F75a177f9a1D00d84D607d75fB4e1OptimismExactly Protocol on Optimism let one deposited Uniswap V3 NFT count as collateral for every standard vault owned by the same minter. In transaction 0x18e34ce214211afedb6008c0cd00d476ce71222643521dbff5e2b65cdb2ccb80, the attacker used one WETH/USDC.e Uniswap V3 position, deposited it once for vault 40, then borrowed as though vaults 40 through 69 all held that same position. The root cause was that Univ3CollateralToken keyed deposited-position ownership by vault.minter() instead of by vault id or the vault-specific NFT custody address. VaultController then trusted the wrapper balance and approved debt against duplicated collateral value, draining 44,218,394,393 raw USDC and 7,019,930,123 raw USDC.e before the attacker exited with 19.862092726168777267 WETH.
Exactly uses VaultController at 0x05498574BD0Fa99eeCB01e1241661E7eE58F8a85 to mint standard vaults and compute borrowing power. Each standard vault stores its minter address and exposes tokenBalance(token) by calling IERC20(token).balanceOf(address(this)), so VaultController depends on wrapper tokens to report correct collateral balances.
0x18e34ce214211afedb6008c0cd00d476ce71222643521dbff5e2b65cdb2ccb80Uniswap V3 NFT collateral is handled by Univ3CollateralToken at 0x7131FF92a3604966d7D96CCc9d596F7e9435195c. Physical custody is separate: NftVaultController at 0xd5832070A81d607E8932B524f507B47117564CD3 deploys one dedicated VaultNft contract per standard vault id. That means the real NFT is supposed to be vault-specific even if the same EOA minted multiple vaults.
USDI at 0x889be273BE5F75a177f9a1D00d84D607d75fB4e1 is the debt token and reserve holder. At the exploit fork point the contract held 44,218,394,393 raw USDC in its primary reserve and 13,038,928,715 raw USDC.e in its secondary reserve.
The broken invariant is straightforward: each vault's borrowing power must depend only on collateral actually assigned to that vault. Exactly broke that invariant inside its Uniswap V3 wrapper. NftVaultController and VaultNft preserved vault-specific custody, but Univ3CollateralToken.deposit recorded the position under the vault minter address rather than the specific vault. Later, Univ3CollateralToken.balanceOf(vault) resolved the queried vault back to its minter and summed every stored position for that minter. VaultController.get_vault_borrowing_power consumed that inflated wrapper balance through vault.tokenBalance(token_address). The result was duplicated collateral accounting across all same-minter vaults even though only one VaultNft actually held the NFT. Because borrowing and reserve-withdrawal logic trusted that accounting, one 3 WETH LP NFT funded debt across thirty vaults.
The custody path and the solvency path diverged.
NftVaultController.mintVault(uint96 id) deploys a new VaultNft for each standard vault id:
function mintVault(uint96 id) public returns (address) {
if (_vaultId_nftVaultAddress[id] == address(0)) {
address vault_address = _vaultController.vaultAddress(id);
if (vault_address != address(0)) {
address nft_vault_address =
address(new VaultNft(id, vault_address, address(_vaultController), address(this)));
_vaultId_nftVaultAddress[id] = nft_vault_address;
}
}
return _vaultId_nftVaultAddress[id];
}
That code establishes one NFT custody contract per vault id. The analysis in root_cause.json is therefore correct that the deposited position for vault 40 should remain specific to vault 40.
The wrapper then broke this boundary. Its verified ABI confirms both deposit(uint256,uint96) and depositedPositions(address) are keyed around an address owner list, and the reported breakpoint identifies the problematic logic:
// Univ3CollateralToken
add_to_list(vault.minter(), tokenId);
...
address account = V.minter();
for (...) {
totalValue += get_token_value(_underlyingOwners[account][i]);
}
Because both write and read paths use vault.minter(), every vault created by the same EOA points to the same _underlyingOwners[minter] list. The dedicated VaultNft custody contract is ignored by the accounting path.
VaultController then amplified the mistake. Its verified source computes borrowing power by iterating enabled collateral tokens and reading each vault's token balance:
function get_vault_borrowing_power(IVault vault) private view returns (uint192) {
for (uint192 i = 1; i <= _tokensRegistered; ++i) {
address token_address = _enabledTokens[i - 1];
uint256 balance = vault.tokenBalance(token_address);
...
uint192 token_value = safeu192(truncate(truncate(raw_price * balance * _tokenId_tokenLTV[i])));
total_liquidity_value = total_liquidity_value + token_value;
}
}
For wrapper collateral, Vault.tokenBalance(token_address) dispatches to IERC20(token_address).balanceOf(address(this)). Since Univ3CollateralToken.balanceOf(vault) aggregates by minter rather than by vault, VaultController sees the same NFT value in every same-minter vault and authorizes repeated debt creation.
The exploit trace for 0x18e34ce214211afedb6008c0cd00d476ce71222643521dbff5e2b65cdb2ccb80 shows the position id 1077691 being valued repeatedly through V3PositionValuator::getValue(1077691) while VaultController::borrowUsdi(...) is called across many vault ids. The same trace also shows USDI::withdrawSecondaryReserve(7019930123) near the end of the transaction, proving that the duplicated borrowing power was converted into realized reserve extraction rather than remaining theoretical.
The attacker cluster consisted of EOA 0x1020c949c1c8658cef8e473dbd3631afe68c1938 and helper contract 0x2fa6fe6b5c8e372fabfc6eb8fa8118cb8ffc3f60. The EOA first deployed the helper in tx 0x03677609fbd178201016b26eb0f9ebfab511d3b6d89143f9f34c3fa07a2683c9, then prepared the same-minter vault set in tx 0xa22bf777ad5ca6f30083b06339e05330aa0bd06f482b4a8e9b3f789f3269f906.
The exploit execution happened in tx 0x18e34ce214211afedb6008c0cd00d476ce71222643521dbff5e2b65cdb2ccb80:
3 WETH.1077691, then deposited that NFT once into the Exactly collateral wrapper for vault 40.40 through 69.VaultController::borrowUsdi(...) calls for vault ids in that range, and the balance diff shows the primary USDC reserve at USDI moved from 44,218,394,393 to 0.USDI::withdrawSecondaryReserve(7019930123), extracting 7,019,930,123 raw USDC.e from the secondary reserve.19.862092726168777267 WETH at the attacker EOA after the transaction.The exploit exhausted Exactly's primary USDC reserve held by USDI and partially depleted its secondary USDC.e reserve. The measured losses are:
44,218,394,393 raw units (44,218.394393 USDC at 6 decimals)7,019,930,123 raw units (7,019.930123 USDC.e at 6 decimals)The economic effect was broader than the immediate reserve drain. Thirty same-minter vaults were left carrying debt that had been authorized by one NFT position that only existed in one dedicated custody vault.
0x18e34ce214211afedb6008c0cd00d476ce71222643521dbff5e2b65cdb2ccb800x03677609fbd178201016b26eb0f9ebfab511d3b6d89143f9f34c3fa07a2683c9, 0xa22bf777ad5ca6f30083b06339e05330aa0bd06f482b4a8e9b3f789f3269f906VaultController: 0x05498574BD0Fa99eeCB01e1241661E7eE58F8a85Univ3CollateralToken: 0x7131FF92a3604966d7D96CCc9d596F7e9435195cNftVaultController: 0xd5832070A81d607E8932B524f507B47117564CD3USDI: 0x889be273BE5F75a177f9a1D00d84D607d75fB4e1VaultController, Univ3CollateralToken, and NftVaultController0x1020c949c1c8658cef8e473dbd3631afe68c1938