This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b4300x66357dcace80431aee0a7507e2e361b7e2402370Avalanche0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0Avalanche0x061da45081ace6ce1622b9787b68aa7033621438AvalancheOn Avalanche block 26,343,614, transaction 0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430 let an unprivileged attacker drain value from Platypus Finance in a single flash-loan transaction. The attacker EOA 0xeff003d64046a6f521ba31f39405cb720e953958 called exploit contract 0x67afdd6489d40a01dae65f709367e1b1d18a5322, borrowed 44,000,000 USDC from Aave V3, minted and staked LPUSDC, borrowed 41,794,533.641783253909672 USP against that LP collateral, then used MasterPlatypusV4.emergencyWithdraw(4) to pull the LP collateral back out before PlatypusTreasure re-evaluated the position on the reduced collateral base.
The root cause is a stale-collateral ordering bug across two public Platypus components. Historical MasterPlatypusV4 implementation 0xc007f27b757a782c833c568f5851ae1dfe0e6ec7 called platypusTreasure.isSolvent(msg.sender, address(poolInfo[_pid].lpToken), true) before transferring the LP token back to the user and before zeroing user.amount, while historical PlatypusTreasure implementation 0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5 treated masterPlatypus.getUserInfo(pid, user).amount as the source of LP collateral. That ordering let the solvency check observe collateral that was about to disappear. After repaying the flash loan, the attacker retained token balances valued at USD under the report's stated one-dollar pricing assumption for USP and the retained stablecoins.
41,544,683.636572669965801520Platypus split the relevant LP-backed borrowing logic across two contracts:
MasterPlatypusV4 at proxy 0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0 managed staked LP positions by pool id.PlatypusTreasure at proxy 0x061da45081ace6ce1622b9787b68aa7033621438 let users borrow USP against collateral, including LP collateral.Pool at proxy 0x66357dcace80431aee0a7507e2e361b7e2402370 minted and redeemed the LPUSDC token 0xaef735b1e7ecfaf8209ea46610585817dc0a2e16.The important design detail is that PlatypusTreasure did not keep an internal LP collateral balance for LP markets. Instead, it delegated LP collateral accounting to MasterPlatypus. For the LPUSDC market, solvency and borrow-limit checks flowed through MasterPlatypusV4.getUserInfo(pid, user).amount. That made the staking contract's ordering around emergencyWithdraw security-critical for lending safety.
The attack was ACT-style because every required component was public and permissionless:
flashLoanSimple for USDC.MasterPlatypusV4.deposit and MasterPlatypusV4.emergencyWithdraw were public.PlatypusTreasure.borrow was public for eligible collateral positions.No privileged role, compromised key, hidden mempool visibility, or attacker-specific artifact was required.
This incident is an ATTACK root cause, not a pure MEV arbitrage. PlatypusTreasure expected a borrower with outstanding USP debt to remain backed by the LP position that its solvency logic used as collateral. That invariant failed because the collateral source lived in another contract and MasterPlatypusV4.emergencyWithdraw validated solvency too early.
The historical MasterPlatypusV4 code performed the check against the pre-withdraw staking state. The historical PlatypusTreasure code then interpreted that same staking state as current collateral. As a result, the borrower could pass isSolvent, receive the LP tokens back, and only afterwards have the staked amount cleared. The lending side was never given a chance to reject the withdrawal on the post-withdraw state that actually mattered.
The exploitable breakpoint is therefore concrete and code-level: MasterPlatypusV4.emergencyWithdraw(uint256) invoked platypusTreasure.isSolvent before pool.lpToken.transfer(address(msg.sender), user.amount) and before user.amount = 0. The broken invariant is equally concrete: a user with positive USP debt must not be able to remove the LP position that PlatypusTreasure uses as collateral.
The validator independently retrieved the verified source for historical implementation 0xc007f27b757a782c833c568f5851ae1dfe0e6ec7. The critical portion of MasterPlatypusV4.emergencyWithdraw was:
function emergencyWithdraw(uint256 _pid) public nonReentrant {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][msg.sender];
if (address(platypusTreasure) != address(0x00)) {
(bool isSolvent, ) = platypusTreasure.isSolvent(
msg.sender,
address(poolInfo[_pid].lpToken),
true
);
require(isSolvent, "remaining amount exceeds collateral factor");
}
pool.lpToken.transfer(address(msg.sender), user.amount);
pool.sumOfFactors -= user.factor;
user.amount = 0;
user.factor = 0;
user.rewardDebt = 0;
}
Snippet origin: historical verified MasterPlatypusV4 implementation on Snowtrace.
This ordering is the bug. The function asked PlatypusTreasure whether the position was solvent before it reduced the LP collateral that PlatypusTreasure depends on.
The validator also independently retrieved the verified source for historical implementation 0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5. The relevant PlatypusTreasure logic was:
function _isSolvent(
address _user,
ERC20 _token,
bool _open
) internal view returns (bool solvent, uint256 debtAmount) {
uint256 debtShare = userPositions[_token][_user].debtShare;
if (debtShare == 0) return (true, 0);
debtAmount =
(debtShare * (totalDebtAmount + _interestSinceLastAccrue())) /
totalDebtShare;
solvent = debtAmount <= (_open ? _borrowLimitUSP(_user, _token) : _liquidateLimitUSP(_user, _token));
}
function _getCollateralAmount(ERC20 _token, address _user) internal view returns (uint256) {
CollateralSetting storage setting = collateralSettings[_token];
if (setting.isLp) {
return setting.masterPlatypus.getUserInfo(setting.pid, _user).amount;
} else {
return userPositions[_token][_user].collateralAmount;
}
}
function _borrowLimitUSP(address _user, ERC20 _token) internal view returns (uint256) {
uint256 amount = _getCollateralAmount(_token, _user);
uint256 totalUSD = _tokenPriceUSD(_token, amount);
return (totalUSD * collateralSettings[_token].collateralFactor) / 10000;
}
Snippet origin: historical verified PlatypusTreasure implementation on Snowtrace.
This proves the cross-contract dependency. For LP collateral, PlatypusTreasure directly trusted MasterPlatypusV4.getUserInfo(pid, user).amount. That meant the lending-side solvency answer was only as current as the staking-side state transition order.
The seed trace for transaction 0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430 shows the exploit in the exact order required by the buggy code:
PlatypusTreasure::positionView(... LPUSDC ...) -> borrowLimitUSP: 41794533641783253909672000
PlatypusTreasure::borrow(LPUSDC, 41794533641783253909672000)
MasterPlatypusV4::emergencyWithdraw(4)
PlatypusTreasure::isSolvent(attacker, LPUSDC, true)
MasterPlatypusV4::getUserInfo(4, attacker)
-> amount: 44000100592104
Asset::transfer(attacker, 44000100592104)
storage change: user.amount -> 0
Pool::withdraw(USDC, 44000100592104, 0, attacker, ...)
Snippet origin: collected Avalanche execution trace for the incident transaction.
That trace matters for two reasons:
44000100592104 LP units is read inside the isSolvent call.The trace therefore confirms the reported breakpoint at runtime, not just in source code.
The post-withdraw condition described in root_cause.json is also evidence-backed. The same transaction sequence leaves the attacker with zero staked LP in MasterPlatypus while debt remains in PlatypusTreasure:
MasterPlatypusV4.getUserInfo(4, attacker).amount == 0PlatypusTreasure.positionView(attacker, LPUSDC).debtAmountUSP > 0That is the precise non-monetary success predicate: collateral was removed while the debt position remained open.
0xeff003d64046a6f521ba31f39405cb720e953958 called exploit contract 0x67afdd6489d40a01dae65f709367e1b1d18a5322 in transaction 0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430.44,000,000 USDC from Aave V3 using flashLoanSimple.44,000,100,592,104 LPUSDC.MasterPlatypusV4 pool id 4.PlatypusTreasure.positionView and borrowed the full reported limit, 41,794,533.641783253909672 USP.MasterPlatypusV4.emergencyWithdraw(4). Because the solvency check ran before the staking state was reduced, PlatypusTreasure still saw the full LP stake and approved the withdrawal.43,999,999.921036 USDC.44,022,000 USDC to Aave and kept the remaining USP and stablecoins.The balance diff confirms the end-state holdings on the exploit contract:
[
{ "token_symbol": "USP", "amount": "33044533641783253909672000", "decimal": 18 },
{ "token_symbol": "USDC", "amount": "2403762189097", "decimal": 6 },
{ "token_symbol": "USDC.e", "amount": "1946900836223", "decimal": 6 },
{ "token_symbol": "USDT", "amount": "1552550943906", "decimal": 6 },
{ "token_symbol": "USDT.e", "amount": "1217581624092", "decimal": 6 },
{ "token_symbol": "BUSD", "amount": "687369440244482886082500", "decimal": 18 },
{ "token_symbol": "DAI.e", "amount": "691984961226933170047020", "decimal": 18 }
]
Snippet origin: collected post-transaction balance-diff artifact for the exploit contract.
The immediate security impact was an LP-backed borrowing invariant failure in Platypus. A borrower ended the transaction with zero LP collateral in MasterPlatypus while still owing USP in PlatypusTreasure. That is the protocol-level failure condition even before any mark-to-market profit calculation.
The economic impact is the attacker's retained balance set after Aave repayment. Under the explicit pricing assumption used in the audited root cause artifact, USP and each retained stablecoin are valued at 1 USD per token. On that basis, the post-transaction holdings total exactly 41,544,683.636572669965801520 USD. The stated flash-loan fee is 22,000 USDC, and the seed balance diff separately records a gas payment of 0.56432484 AVAX by the sender EOA.
Losses recorded in smallest units:
USP: "33044533641783253909672000" with 18 decimalsUSDC: "2403762189097" with 6 decimalsUSDC.e: "1946900836223" with 6 decimalsUSDT: "1552550943906" with 6 decimalsUSDT.e: "1217581624092" with 6 decimalsBUSD: "687369440244482886082500" with 18 decimalsDAI.e: "691984961226933170047020" with 18 decimalsAffected public protocol components:
0x66357dcace80431aee0a7507e2e361b7e24023700xff6934aac9c94e1c39358d4fdcf70aeca77d0ab00x061da45081ace6ce1622b9787b68aa70336214380x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430 on Avalanche block 26,343,614.MasterPlatypusV4 proxy 0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0 and implementation 0xc007f27b757a782c833c568f5851ae1dfe0e6ec7.PlatypusTreasure proxy 0x061da45081ace6ce1622b9787b68aa7033621438 and implementation 0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5.0x66357dcace80431aee0a7507e2e361b7e2402370 and LPUSDC token 0xaef735b1e7ecfaf8209ea46610585817dc0a2e16.