Platypus LP Cross-Asset Mispricing
Exploit Transactions
0x4b544e5ffb0420977dacb589a6fb83e25347e0685275a3327ee202449b3bfac6Victim Addresses
0xbe52548488992cc76ffa1b42f3a58f646864df45Avalanche0x06f01502327de1c37076bea4689a7e44279155e9Avalanche0xdea7bf752ef25301dbb2e9288338a1a9013ec194AvalancheLoss Breakdown
Similar Incidents
Platypus Stale Collateral Withdrawal
41%Allbridge Pool Mispricing
25%VistaFinance oracle mispricing enables VISTA flash-loan arbitrage drain
23%TRU reserve mispricing attack drains WBNB from pool
22%ENF Redeem Decimal Mis-Scaling
22%Conic crvUSD Oracle Exploit
22%Root Cause Analysis
Platypus LP Cross-Asset Mispricing
1. Incident Overview TL;DR
Platypus Finance was exploited on Avalanche in block 32470737 by transaction 0x4b544e5ffb0420977dacb589a6fb83e25347e0685275a3327ee202449b3bfac6. The transaction was adversary-crafted, included from EOA 0xc64afc460290ed3df848f378621b96cb7179521a, and routed execution through attacker contract 0x16a3c9e492dee1503f46dea84c52c6a0608f1ed8. The path was permissionless: public working capital, a public Aave V3 USDC flash loan, public Platypus pool and asset contracts, and no privileged roles or private orderflow. The attacker contract started with 24333340760 raw USDC units, borrowed 85000000000 more, deposited the full 109333340760 into LP-USDC, withdrew USDT.e from the same aggregate account via withdrawFromOtherAsset, swapped that USDT.e back to USDC, repaid Aave 85042500000 raw USDC units, and finished with 30128157738 raw USDC units. The net profit predicate was therefore 5794816978 raw USDC units on the attacker contract, while gas was paid separately by the EOA in 0.0253052525 AVAX.
The root cause is a share-accounting bug in the historical Platypus pool implementation at 0x7e1333a39abed9a5664661957b80ba01d2702b1e. withdrawFromOtherAsset treated LP balances from different assets as interchangeable by decimals only, even though each asset had a different liability-per-share exchange rate. _deposit compounded the issue by minting extra LP when the pool was globally under-covered. That let cheap LP-USDC shares redeem too much LP-USDT.e value and leak value out of the pool.
2. Key Background
Platypus asset accounting depends on three variables per asset: cash, liability, and LP totalSupply. An LP token's economic value is not just its decimals; it is the asset's liability-per-share ratio, liability / totalSupply. Cross-asset conversion is only safe if the protocol normalizes between those per-asset exchange rates.
The historical Platypus design also rewarded deposits into an under-covered system. When the global equilibrium coverage ratio dropped below 1e18, _deposit scaled LP mint output upward with 1 / eqCov, so an under-covered asset could mint more LP units than the liability added by the deposit.
The exploit required two assets in the same aggregate account. LP-USDC at 0x06f01502327de1c37076bea4689a7e44279155e9 and LP-USDT.e at 0xdea7bf752ef25301dbb2e9288338a1a9013ec194 both resolved to aggregate account 0x1655E447B7281E014E54Cf0c1aD976b006E2B3DC, which made withdrawFromOtherAsset available between them. The seed trace also shows LP-USDC was under-covered while LP-USDT.e remained withdrawable, which is the exact condition that made the value mismatch exploitable.
3. Vulnerability Analysis & Root Cause Summary
This incident is an ATTACK, not a pure MEV arbitrage, because the protocol's accounting allowed the attacker to withdraw more liability-equivalent value than it burned. The core bug sits in historical Pool::withdrawFromOtherAsset, which converted LP between assets using decimals instead of each asset's liability-per-share exchange rate. That meant LP minted cheaply in one asset could be spent as if it were equally valuable LP in a better-covered asset. Historical _deposit made the mispricing larger by over-minting LP whenever the pool-wide equilibrium coverage ratio was below 1e18. The invariant that should have held was straightforward: the liability burned on the initial asset must match the liability-equivalent value that authorizes the wanted-asset withdrawal. Instead, the protocol burned only LP-USDC liability while pricing the withdrawal from LP-USDT.e's own share ratio. The trace-backed outcome was a one-transaction extraction of USDT.e value that was later converted back to USDC and retained as profit.
4. Detailed Root Cause Analysis
4.1 Invariant and breakpoint
The violated invariant was:
// Cross-asset withdrawals must conserve liability-equivalent value.
burnedInitialAssetLiability == wantedAssetLiabilityUsedForWithdrawal;
The historical pool logic, as summarized from the verified implementation referenced in the collected evidence, broke that invariant at the conversion step:
// Historical Pool logic
liquidity = amount * initialAsset.totalSupply() / initialAsset.liability();
if (eqCov < 1e18) {
liquidity = liquidity.wdiv(eqCov);
}
liquidityInInitialAssetDP =
liquidity * 10**initialAsset.decimals() / 10**wantedAsset.decimals();
burnedLiability =
initialAsset.liability() * liquidityInInitialAssetDP / initialAsset.totalSupply();
withdrawnAmount = _withdrawFrom(wantedAsset, liquidity);
The bug is the mismatch between burnedLiability, which is computed from the initial asset, and withdrawnAmount, which is priced from the wanted asset's own accounting.
4.2 Pre-state and why the exploit window existed
The seed trace immediately before the deposit path shows the exact state that made the exploit possible:
Asset::totalSupply() -> 920270929028 // LP-USDC
Asset::liability() -> 873821945995
Asset::cash() -> 689203949727
Asset::totalSupply() -> 428491997999 // LP-USDT.e
Asset::liability() -> 428470175659
Asset::cash() -> 562535689285
Independent Avalanche RPC validation at the fork point also confirms:
PlatypusPool::getEquilibriumCoverageRatio() = 976486188780554184
LP-USDC aggregateAccount() = 0x1655E447B7281E014E54Cf0c1aD976b006E2B3DC
LP-USDT.e aggregateAccount() = 0x1655E447B7281E014E54Cf0c1aD976b006E2B3DC
Those numbers matter because LP-USDC was under-covered and globally the pool was below equilibrium, so _deposit minted inflated LP-USDC. LP-USDT.e, by contrast, still had enough coverage to satisfy the post-withdrawal checks.
4.3 The value leak in the exploit transaction
The transaction trace shows the whole leak in one path:
Pool::deposit(USDC, 109333340760, attacker, ...)
emit Deposit(... amount: 109333340760, liquidity: 117917773047, ...)
Pool::withdrawFromOtherAsset(USDC, USDT.e, 117917773047, 0, attacker, ...)
Asset::removeLiability(111667061818)
Asset::transferUnderlyingToken(attacker, 115240655531)
emit Withdraw(... amount: 115240655531, liquidity: 117917773047, ...)
Pool::swap(USDT.e, USDC, 115240655531, 0, attacker, ...)
emit Swap(... fromAmount: 115240655531, toAmount: 115170657738, ...)
The deposit leg added only 109333340760 liability units to LP-USDC but minted 117917773047 LP-USDC units because the pool was under-covered. The withdrawal leg then treated those 117917773047 LP-USDC units as 117917773047 units of LP-USDT.e withdrawal liquidity because both assets used 6 decimals. The protocol therefore transferred 115240655531 raw USDT.e units out of LP-USDT.e while removing only 111667061818 raw liability units from LP-USDC. That gap is the protocol loss mechanism.
The supporting asset implementation shows that the asset contracts themselves simply executed whatever accounting the pool instructed:
function transferUnderlyingToken(address to, uint256 amount) external onlyPool {
IERC20(_underlyingToken).safeTransfer(to, amount);
}
function removeCash(uint256 amount) external onlyPool {
require(_cash >= amount, "PTL:INSUFFICIENT_CASH");
_cash -= amount;
}
function removeLiability(uint256 amount) external onlyPool {
require(_liability >= amount, "PTL:INSUFFICIENT_LIABILITY");
_liability -= amount;
}
That is why the code-level breakpoint belongs in the pool, not the asset proxies.
4.4 Exploit conditions and violated security principles
The ACT window required four concrete conditions:
- The attacker needed two assets in the same aggregate account.
- The initial asset's LP share price needed to be cheaper than the wanted asset's LP share price.
- The wanted asset still needed to satisfy the coverage threshold after the withdrawal.
- Public capital sourcing, flash loans, deposit,
withdrawFromOtherAsset, and unwind had to remain permissionless.
Those conditions were all met in the incident. The security principles violated were equally concrete: cross-asset share accounting must normalize by per-asset exchange rate, under-coverage mint incentives must not double as cross-asset redemption rights, and aggregate-account withdrawals must conserve liability value.
5. Adversary Flow Analysis
The adversary strategy was a single-transaction, flashloan-assisted share-misvaluation attack. The adversary cluster consisted of EOA 0xc64afc460290ed3df848f378621b96cb7179521a, which submitted the transaction, and attacker contract 0x16a3c9e492dee1503f46dea84c52c6a0608f1ed8, which performed the flash loan, Platypus interactions, repayment, and profit capture. The primary victim-side contracts were the Platypus pool proxy 0xbe52548488992cc76ffa1b42f3a58f646864df45, LP-USDC asset proxy 0x06f01502327de1c37076bea4689a7e44279155e9, and LP-USDT.e asset proxy 0xdea7bf752ef25301dbb2e9288338a1a9013ec194.
The execution flow had three stages:
- Public working capital and flash loan. The attacker contract began with
24333340760raw USDC units and then borrowed85000000000raw USDC units from Aave V3, giving it109333340760raw USDC units available for the exploit. - Mint cheap LP-USDC. The attacker deposited the full
109333340760raw USDC units into Platypus and received117917773047LP-USDC units because_depositscaled the mint output by1 / eqCovin an under-covered pool. - Redeem LP-USDC as LP-USDT.e and unwind. The attacker called
withdrawFromOtherAsset(USDC, USDT.e, 117917773047, ...), which burned only111667061818LP-USDC liability yet released115240655531raw USDT.e units. It then swapped those115240655531raw USDT.e units back into115170657738raw USDC units, repaid85042500000raw USDC units to Aave, and retained the difference.
The inclusion feasibility was fully permissionless. Every primitive used in the transaction was public, and the trace shows no privileged role, no private calldata dependency, and no attacker-only helper beyond the attacker's own deployed contract.
6. Impact & Losses
The measurable protocol loss was borne by LP-USDC. The collector balance diff shows LP-USDC's USDC balance dropped from 689203949727 to 683366632749, a loss of 5837316978 raw USDC units (5837.316978 USDC). Of that extracted value, 5794816978 raw USDC units (5794.816978 USDC) remained on the attacker contract and 42500000 raw USDC units (42.5 USDC) were paid as the Aave flash-loan premium. The sender EOA separately paid 25305252500000000 wei (0.0253052525 AVAX) in gas.
In practical terms, the exploit worsened LP-USDC's already under-covered state by letting the attacker redeem better-priced LP-USDT.e value while underpaying in LP-USDC liability terms. The loss was immediate, single-transaction, and fully realized on-chain.
7. References
- Seed transaction
0x4b544e5ffb0420977dacb589a6fb83e25347e0685275a3327ee202449b3bfac6metadata, trace, and balance diff under the collector seed artifacts. - Historical Platypus pool implementation reference at
0x7e1333a39abed9a5664661957b80ba01d2702b1e. - Historical Platypus asset implementation at
0xb00c1f6ad87a832e39d15581fcd7ca39edf3a9c7, especiallytransferUnderlyingToken,removeCash, andremoveLiability. - Avalanche RPC validation of
getEquilibriumCoverageRatio(),cash(),liability(),totalSupply(), andaggregateAccount()at the exploit fork point.