Calculated from recorded token losses using historical USD prices at the incident time.
0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe0xd417c28af20884088f600e724441a3bab38b22ccOptimism0x9aa024d3fd962701ed17f76c17cab22d3dc9d92dOptimism0x3ae354d7e49039ccd582f1f3c9e65034ffd17badOptimismArcadia Finance on Optimism was exploited in transaction 0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe at block 106676495. The attacker flash-loaned seed WETH and USDC, created two new Arcadia vaults, borrowed nearly all available liquidity from Arcadia's WETH and USDC lending pools, then self-liquidated both vaults from inside vaultManagementAction() after withdrawing all collateral.
The root cause is a state-ordering bug in Arcadia's Vault logic at 0x3ae354d7e49039ccd582f1f3c9e65034ffd17bad. Vault.liquidateVault() clears isTrustedCreditorSet, trustedCreditor, liquidator, and then fixedLiquidationCost before control returns to the enclosing Vault.vaultManagementAction() frame. Because vaultManagementAction() recomputes getUsedMargin() only after the external action callback returns, the final solvency check sees zero used margin and does not revert, even though the vault's debt-backed collateral has already been removed.
Arcadia vaults are margin accounts. Their core safety condition is that a vault with open debt must remain solvent under getCollateralValue() >= getUsedMargin().
Anyone can create a fresh Arcadia vault through the public Factory at 0x00cb53780ea58503d3059fc02ddd596d0be926cb. That makes the owner role needed for permissionless in practice.
vaultManagementAction()Arcadia lending pools on Optimism expose public doActionWithLeverage() and liquidateVault(address) entry points. In this incident the affected pools were:
0xd417c28af20884088f600e724441a3bab38b22cc0x9aa024d3fd962701ed17f76c17cab22d3dc9d92dArcadia's MainRegistry allowlists action handlers for vaultManagementAction(). Arcadia's own ActionMultiCall handler at 0x2de7bbaaab48eac228449584f94636bb20d63e65 was allowlisted at the exploited pre-state and can execute arbitrary external calls with arbitrary calldata.
The vulnerability is a solvency-check bypass caused by mutable liability state being cleared during a nested liquidation. vaultManagementAction() withdraws assets to an action handler, lets that handler perform arbitrary external calls, redeposits whatever the handler returns, and only then evaluates the final health check. That design assumes the vault's debt-tracking state is still intact when the check runs. The assumption is false because liquidateVault() can be reached during the callback through the public lending-pool liquidation path, and liquidateVault() clears the trusted creditor state before returning. Once isTrustedCreditorSet becomes false, getUsedMargin() returns zero, so the post-action branch if (usedMargin > fixedLiquidationCost) is skipped entirely. The result is that borrowed assets can be removed permanently while Arcadia is left with empty liquidations and bad debt exposure.
Relevant excerpt from the verified Arcadia Vault logic:
function getUsedMargin() public view returns (uint256 usedMargin) {
if (!isTrustedCreditorSet) return 0;
usedMargin = ITrustedCreditor(trustedCreditor).getOpenPosition(address(this)) + fixedLiquidationCost;
}
function liquidateVault(uint256 openDebt) external returns (...) {
require(msg.sender == liquidator, "V_LV: Only Liquidator");
trustedCreditor_ = trustedCreditor;
isTrustedCreditorSet = false;
trustedCreditor = address(0);
liquidator = address(0);
require(getLiquidationValue() < openDebt + fixedLiquidationCost, "V_LV: liqValue above usedMargin");
fixedLiquidationCost = 0;
IFactory(IMainRegistry(registry).factory()).liquidate(msg.sender);
originalOwner = owner;
_transferOwnership(msg.sender);
}
function vaultManagementAction(address actionHandler, bytes calldata actionData)
external
onlyAssetManager
returns (address, uint256)
{
_withdraw(outgoing.assets, outgoing.assetIds, outgoing.assetAmounts, actionHandler);
ActionData memory incoming = IActionBase(actionHandler).executeAction(actionData);
_deposit(incoming.assets, incoming.assetIds, incoming.assetAmounts, actionHandler);
uint256 usedMargin = getUsedMargin();
if (usedMargin > fixedLiquidationCost) {
require(getCollateralValue() >= usedMargin, "V_VMA: Vault Unhealthy");
}
return (trustedCreditor, vaultVersion);
}
The exploit used one adversary-crafted transaction and no privileged roles, private keys, or victim cooperation.
0xd3641c912a6a4c30338787e3c464420b561a9467 called attacker contract 0x01a4d9089c243ccaebe40aa224ad0cab573b83c6.29.847813623947075968 WETH and 11916676700 USDC from Aave.148239068119735379843 WETH and 59433383500 USDC through doActionWithLeverage(), which transferred the borrowed assets to allowlisted ActionMultiCall.vaultManagementAction() on each vault. During those calls, the vault withdrew its assets to ActionMultiCall.0x3a524db634a1683917040752eb9cac643c685403 and 0x5b0bceedc1a8d173f71fd81807600bad3c2e36f3 pulled the withdrawn assets from ActionMultiCall and called the public Arcadia liquidation entry points.Vault.liquidateVault(openDebt). At that point the vault cleared the trusted-creditor state and transferred vault ownership to Arcadia's liquidator 0xd2a34731586bd10b645f870f4c9dcaf4f9e3823c.vaultManagementAction() frame. Because isTrustedCreditorSet had already been cleared, getUsedMargin() returned zero and the final solvency branch was skipped.Representative trace excerpt from the WETH side:
0x13C0...::vaultManagementAction(ActionMultiCall, ...)
ActionMultiCall::executeAction(...)
WETH9::transferFrom(ActionMultiCall, attacker-contract, 178086881743682455811)
WETH pool::liquidateVault(0x13C0...)
Liquidator::startAuction(0x13C0..., 148387307187855115223, 3000000000000000000)
Vault::liquidateVault(148387307187855115223)
Factory::liquidate(Liquidator)
emit TrustedMarginAccountChanged(0x0, 0x0)
The USDC side repeats the same pattern against 0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d, with liquidation started on vault 0x6c6515cabf1b90443634be4d88530b226a13a921.
The exploit conditions were all permissionless and public:
ActionMultiCall supplied the arbitrary callback surface.liquidateVault(address) functions were public and reachable during that callback.The attacker's execution flow had three concrete stages.
The attacker borrowed bootstrap liquidity from Aave in the same transaction. This supplied the initial collateral needed to open two margin accounts and pass Arcadia's initial leverage checks.
The attacker created vaults 1157 and 1158, opened trusted margin accounts for the WETH and USDC Arcadia pools, deposited the flash-loaned collateral, and borrowed almost the full available pool balances. The critical design point is that doActionWithLeverage() optimistically transfers borrowed funds to the action handler before only verifying post-action vault state later.
Relevant Arcadia lending-pool logic:
function doActionWithLeverage(...) external {
_deposit(amountBorrowedWithFee, vault);
asset.safeTransfer(actionHandler, amountBorrowed);
(address trustedCreditor, uint256 vaultVersion) = IVault(vault).vaultManagementAction(actionHandler, actionData);
require(trustedCreditor == address(this) && isValidVersion[vaultVersion], "LP_DAWL: Reverted");
}
function liquidateVault(address vault) external {
uint256 openDebt = maxWithdraw(vault);
liquidationInitiator[vault] = msg.sender;
ILiquidator(liquidator).startAuction(vault, openDebt, maxInitiatorFee);
_withdraw(openDebt, vault, vault);
}
After the nested self-liquidation bypass succeeded on both vaults, the attacker repaid the Aave flash loans and swept the residual profits back to the sender EOA. The final sender balances were:
6659167178883893 wei native and 0 USDC148230760062633611413 wei native and 59427425162 USDC148224100895454727520 wei native and 59427425162 USDCThe trace also shows the exploit contract transferring 59427425162 USDC to the sender EOA and withdrawing 148224144212923406305 wrapped native value before forwarding it as native ETH-equivalent balance to the EOA.
The exploit immediately drained nearly all liquid borrowable assets from both Arcadia lending pools involved in the transaction:
148239068119735379843 WETH from 0xd417c28af20884088f600e724441a3bab38b22cc59433383500 USDC from 0x9aa024d3fd962701ed17f76c17cab22d3dc9d92dThe vaults that backed those liabilities were left empty and transferred into liquidation ownership after their collateral had already been withdrawn. That means the protocol entered liquidation with zero-collateral positions instead of recoverable, overcollateralized debt. The attacker realized immediate positive profit in the same transaction, while Arcadia LPs were left exposed to bad debt equal to the unrecovered pool outflows.
0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe/workspace/session/artifacts/collector/seed/10/0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe/trace.cast.log/workspace/session/artifacts/collector/seed/10/0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe/balance_diff.json/workspace/session/artifacts/collector/seed/10/0x00cb53780ea58503d3059fc02ddd596d0be926cb/src/Factory.sol/workspace/session/artifacts/collector/seed/10/0xd417c28af20884088f600e724441a3bab38b22cc/lib/arcadia-lending/src/LendingPool.sol/workspace/session/artifacts/collector/seed/10/0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d/lib/arcadia-lending/src/LendingPool.solhttps://optimistic.etherscan.io/address/0x3ae354d7e49039ccd582f1f3c9e65034ffd17bad#codehttps://optimistic.etherscan.io/address/0x2de7bbaaab48eac228449584f94636bb20d63e65#code