Arcadia Self-Liquidation Bypass
Exploit Transactions
0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afeVictim Addresses
0xd417c28af20884088f600e724441a3bab38b22ccOptimism0x9aa024d3fd962701ed17f76c17cab22d3dc9d92dOptimism0x3ae354d7e49039ccd582f1f3c9e65034ffd17badOptimismLoss Breakdown
Similar Incidents
Hundred hWBTC Donation Exploit
31%dForce Oracle Reentrancy Liquidation
25%CauldronV4 solvency-check bypass enables uncollateralized MIM borrowing
23%BankrollNetworkStack self-buy dividend inflation exploit
22%UniswapV4Router04 swap(bytes,uint256) Calldata-Offset Auth Bypass
22%Aave AMM LP Oracle Manipulation Through Delegated Recursive LP Looping
21%Root Cause Analysis
Arcadia Self-Liquidation Bypass
1. Incident Overview TL;DR
Arcadia 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.
2. Key Background
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 vaultManagementAction() permissionless in practice.
Arcadia lending pools on Optimism expose public doActionWithLeverage() and liquidateVault(address) entry points. In this incident the affected pools were:
- WETH pool
0xd417c28af20884088f600e724441a3bab38b22cc - USDC pool
0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d
Arcadia'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.
3. Vulnerability Analysis & Root Cause Summary
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);
}
4. Detailed Root Cause Analysis
The exploit used one adversary-crafted transaction and no privileged roles, private keys, or victim cooperation.
- The sender EOA
0xd3641c912a6a4c30338787e3c464420b561a9467called attacker contract0x01a4d9089c243ccaebe40aa224ad0cab573b83c6. - The attacker contract flash-loaned
29.847813623947075968 WETHand11916676700 USDCfrom Aave. - It created two fresh Arcadia vaults, one denominated in WETH and one in USDC, then opened trusted margin accounts against the two Arcadia lending pools.
- It deposited the flash-loaned seed collateral and borrowed
148239068119735379843 WETHand59433383500 USDCthroughdoActionWithLeverage(), which transferred the borrowed assets to allowlistedActionMultiCall. - The attacker then directly invoked
vaultManagementAction()on each vault. During those calls, the vault withdrew its assets toActionMultiCall. - Inside the callback, attacker helper contracts
0x3a524db634a1683917040752eb9cac643c685403and0x5b0bceedc1a8d173f71fd81807600bad3c2e36f3pulled the withdrawn assets fromActionMultiCalland called the public Arcadia liquidation entry points. - Each lending pool called into the liquidator, which in turn called
Vault.liquidateVault(openDebt). At that point the vault cleared the trusted-creditor state and transferred vault ownership to Arcadia's liquidator0xd2a34731586bd10b645f870f4c9dcaf4f9e3823c. - Control returned to the still-open
vaultManagementAction()frame. BecauseisTrustedCreditorSethad already been cleared,getUsedMargin()returned zero and the final solvency branch was skipped. - The outer call succeeded with no collateral redeposited. Arcadia had already burned the debt tokens at liquidation start, so the vaults ended the transaction empty while the pools had already lost the borrowed assets.
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:
- The attacker only needed to own newly created vaults, which anyone can create.
- A flash loan was sufficient to satisfy the initial leverage step.
- Arcadia's own allowlisted
ActionMultiCallsupplied the arbitrary callback surface. - The lending pools'
liquidateVault(address)functions were public and reachable during that callback.
5. Adversary Flow Analysis
The attacker's execution flow had three concrete stages.
Flash-Loan Bootstrap
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.
Vault Creation and Leveraged Borrow
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);
}
Self-Liquidation and Profit Sweep
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:
- Before:
6659167178883893wei native and0USDC - After:
148230760062633611413wei native and59427425162USDC - Net increase:
148224100895454727520wei native and59427425162USDC
The 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.
6. Impact & Losses
The exploit immediately drained nearly all liquid borrowable assets from both Arcadia lending pools involved in the transaction:
148239068119735379843WETH from0xd417c28af20884088f600e724441a3bab38b22cc59433383500USDC from0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d
The 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.
7. References
- Seed exploit transaction:
0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe - Seed trace:
/workspace/session/artifacts/collector/seed/10/0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe/trace.cast.log - Seed balance diff:
/workspace/session/artifacts/collector/seed/10/0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe/balance_diff.json - Arcadia Factory source artifact:
/workspace/session/artifacts/collector/seed/10/0x00cb53780ea58503d3059fc02ddd596d0be926cb/src/Factory.sol - Arcadia WETH LendingPool source artifact:
/workspace/session/artifacts/collector/seed/10/0xd417c28af20884088f600e724441a3bab38b22cc/lib/arcadia-lending/src/LendingPool.sol - Arcadia USDC LendingPool source artifact:
/workspace/session/artifacts/collector/seed/10/0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d/lib/arcadia-lending/src/LendingPool.sol - Arcadia Vault verified source:
https://optimistic.etherscan.io/address/0x3ae354d7e49039ccd582f1f3c9e65034ffd17bad#code - Arcadia ActionMultiCall verified source:
https://optimistic.etherscan.io/address/0x2de7bbaaab48eac228449584f94636bb20d63e65#code